From 1055125391c7f0589d502a229a2cfd09bc4890d7 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 2 Sep 2026 18:20:22 -0600 Subject: [PATCH] feat(ui): add phone input --- .changeset/warm-taxis-call.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 18 + packages/swingset/src/stories/phone-input.mdx | 72 ++++ .../src/stories/phone-input.stories.tsx | 106 ++++++ .../mosaic/components/phone-input/index.ts | 3 + .../phone-input/phone-input.styles.ts | 65 ++++ .../phone-input/phone-input.test.tsx | 204 +++++++++++ .../components/phone-input/phone-input.tsx | 341 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 2 + 10 files changed, 814 insertions(+) create mode 100644 .changeset/warm-taxis-call.md create mode 100644 packages/swingset/src/stories/phone-input.mdx create mode 100644 packages/swingset/src/stories/phone-input.stories.tsx create mode 100644 packages/ui/src/mosaic/components/phone-input/index.ts create mode 100644 packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts create mode 100644 packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx create mode 100644 packages/ui/src/mosaic/components/phone-input/phone-input.tsx diff --git a/.changeset/warm-taxis-call.md b/.changeset/warm-taxis-call.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/warm-taxis-call.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 946a1014ae1..23412c0dcee 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -50,6 +50,7 @@ const docModules: Record> = { combobox: dynamic(() => import('../stories/combobox.mdx')), input: dynamic(() => import('../stories/input.mdx')), 'input-group': dynamic(() => import('../stories/input-group.mdx')), + 'phone-input': dynamic(() => import('../stories/phone-input.mdx')), item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dabdff44286..55e884481a7 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -99,6 +99,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, @@ -297,6 +305,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, @@ -536,6 +553,7 @@ export const registry: StoryModule[] = [ flowComponentModule, inputModule, inputGroupModule, + phoneInputModule, itemModule, dialogComponentModule, headingModule, diff --git a/packages/swingset/src/stories/phone-input.mdx b/packages/swingset/src/stories/phone-input.mdx new file mode 100644 index 00000000000..0900f5bd33b --- /dev/null +++ b/packages/swingset/src/stories/phone-input.mdx @@ -0,0 +1,72 @@ +import * as PhoneInputStories from './phone-input.stories'; + +# PhoneInput + +The `PhoneInput` combines a searchable country picker and native telephone input into one Mosaic field while exposing a normalized E.164 value. + +It composes `InputGroup` for the telephone field and the inline `Combobox` composition for the country search inside its `Popover`. + +## Playground + + + +## Props + + void', default: '—' }, + { name: 'country', type: 'CountryIso', default: '—' }, + { name: 'defaultCountry', type: 'CountryIso', default: "'us'" }, + { name: 'onCountryChange', type: '(country: CountryIso) => void', default: '—' }, + { name: 'countrySearchPlaceholder', type: 'string', default: "'Search country or code'" }, + { name: 'noResultsMessage', type: 'string', default: "'No countries found'" }, + ]} +/> + +`value` and `defaultValue` use E.164. `onValueChange` reports that normalized value while the visible input formats the national number. Control `country` separately when countries share a calling code. + +## Usage + + + +--- + +## Examples + +### Sizes + + + +### Prefilled international number + + + +### Disabled + + + +### Invalid + + diff --git a/packages/swingset/src/stories/phone-input.stories.tsx b/packages/swingset/src/stories/phone-input.stories.tsx new file mode 100644 index 00000000000..ab8c8405221 --- /dev/null +++ b/packages/swingset/src/stories/phone-input.stories.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { Field } from '@clerk/ui/mosaic/components/field'; +import type { PhoneInputProps } from '@clerk/ui/mosaic/components/phone-input'; +import { PhoneInput } from '@clerk/ui/mosaic/components/phone-input'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './phone-input.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'PhoneInput', + source: 'packages/ui/src/mosaic/components/phone-input/phone-input.tsx', + styles: { + _variants: { + size: { sm: {}, md: {}, lg: {} }, + }, + _defaultVariants: { + size: 'md', + }, + }, +}; + +const stackStyles = { + display: 'grid', + gap: 8, + width: 320, +} as const; + +function knobsAsProps(props: Record) { + return props as unknown as PhoneInputProps; +} + +export function Default(props: Record) { + return ( + + Phone number + + We will send a verification code to this number. + + ); +} + +export function Sizes() { + return ( +
+ + + +
+ ); +} + +export function Prefilled() { + return ( + + Phone number + + Paste an international number to update the detected country. + + ); +} + +export function Disabled() { + return ( + + Phone number + + + ); +} + +export function Invalid() { + return ( + + Phone number + + Enter a valid phone number. + + ); +} diff --git a/packages/ui/src/mosaic/components/phone-input/index.ts b/packages/ui/src/mosaic/components/phone-input/index.ts new file mode 100644 index 00000000000..778f5f9c145 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/index.ts @@ -0,0 +1,3 @@ +export { PhoneInput } from './phone-input'; +export type { PhoneInputProps } from './phone-input'; +export type { CountryIso } from '../../../elements/PhoneInput/countryCodeData'; diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts new file mode 100644 index 00000000000..6602da88e34 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.styles.ts @@ -0,0 +1,65 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + trigger: { + paddingInlineEnd: 0, + paddingInlineStart: space['2.5'], + }, + triggerContent: { + alignItems: 'center', + display: 'flex', + gap: space['0.5'], + }, + flag: { + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: 1, + }, + divider: { + backgroundColor: colorVars['--cl-color-border'], + flexShrink: 0, + height: space['3.5'], + width: '1px', + }, + prefix: { + gap: space['2'], + fontVariantNumeric: 'tabular-nums', + paddingInlineEnd: 0, + paddingInlineStart: space['2'], + }, + control: { + fontVariantNumeric: 'tabular-nums', + paddingInlineStart: space['2'], + }, + popup: { + borderRadius: radiusVars['--cl-radius-lg'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + width: '100%', + }, + countrySearch: { + marginInline: space['2'], + flexShrink: 0, + marginBlockEnd: space['1'], + marginBlockStart: space['2'], + }, + optionName: { + overflow: 'hidden', + flexGrow: 1, + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + optionCode: { + color: colorVars['--cl-color-neutral-faded'], + fontVariantNumeric: 'tabular-nums', + }, + checkHidden: { + visibility: 'hidden', + }, +}); diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx new file mode 100644 index 00000000000..dea137dc0bf --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.test.tsx @@ -0,0 +1,204 @@ +import * as stylex from '@stylexjs/stylex'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Field } from '../field'; +import { scrollAreaRoot, scrollAreaViewport } from '../scroll-area'; +import { PhoneInput } from './phone-input'; + +const scrollClasses = stylex.props(...scrollAreaViewport()).className?.split(' ') ?? []; +const rootClasses = stylex.props(scrollAreaRoot).className?.split(' ') ?? []; +const viewportOnlyClasses = scrollClasses.filter(name => !rootClasses.includes(name)); + +describe('Mosaic PhoneInput', () => { + it('renders one grouped telephone control with the default country', () => { + render(); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + expect(input).toHaveAttribute('type', 'tel'); + expect(input).toHaveAttribute('autocomplete', 'tel-national'); + expect(input).toHaveClass('cl-input', 'cl-phone-input-control'); + expect(input).toHaveAttribute('data-variant', 'headless'); + const countryTrigger = screen.getByRole('button', { name: 'Country, United States' }); + expect(countryTrigger).toHaveClass('cl-input-group-action', 'cl-phone-input-country-trigger'); + expect(countryTrigger).toHaveAttribute('data-size', 'xs'); + expect(countryTrigger).toHaveAttribute('data-variant', 'ghost'); + expect(screen.queryByText('us')).not.toBeInTheDocument(); + expect(document.querySelector('.cl-phone-input-divider')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('+1')).toHaveClass('cl-phone-input-prefix'); + expect(document.querySelector('.cl-phone-input')).toHaveClass('cl-input-group'); + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-size', 'md'); + }); + + it('emits an E.164 value while displaying the national number', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '202 555 0123'); + + expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveValue('(202) 555-0123'); + expect(onValueChange).toHaveBeenLastCalledWith('+12025550123'); + }); + + it('searches countries, preserves the number, and returns focus after selection', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onCountryChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + const searchInput = screen.getByRole('combobox', { name: 'Search countries' }); + expect(searchInput).toHaveClass('cl-combobox-input', 'cl-input'); + expect(searchInput).toHaveAttribute('data-variant', 'headless'); + expect(searchInput.closest('.cl-input-group')).toHaveClass('cl-phone-input-country-search'); + await user.type(searchInput, 'Greece'); + expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list'); + expect(screen.getByRole('option', { name: /Greece/ })).toHaveClass('cl-combobox-option'); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(screen.getByRole('button', { name: 'Country, Greece' })).toBeInTheDocument(); + expect(screen.getByText('+30')).toHaveClass('cl-phone-input-prefix'); + expect(onCountryChange).toHaveBeenCalledWith('gr'); + expect(onValueChange).toHaveBeenLastCalledWith('+302025550123'); + expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveFocus(); + }); + + it('uses the shared ScrollArea treatment for the country list', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + + const popup = document.querySelector('.cl-phone-input-popup'); + const list = screen.getByRole('listbox'); + expect(popup).toBeInTheDocument(); + expect(list).toHaveClass(...rootClasses, ...scrollClasses); + expect(viewportOnlyClasses).not.toHaveLength(0); + expect(viewportOnlyClasses.filter(name => popup?.classList.contains(name))).toEqual([]); + }); + + it('keeps the country search in its own Field scope', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const user = userEvent.setup(); + render( + + Phone number + + , + ); + + await user.click(screen.getByRole('button', { name: 'Country, United States' })); + + expect(screen.getByRole('combobox', { name: 'Search countries' })).toBeInTheDocument(); + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('supports a single form control')); + warn.mockRestore(); + }); + + it('parses a pasted international number', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + await user.click(input); + await user.paste('+30 690 123 4567'); + + expect(screen.getByRole('button', { name: 'Country, Greece' })).toBeInTheDocument(); + expect(input).toHaveValue('690 1234567'); + expect(onValueChange).toHaveBeenLastCalledWith('+306901234567'); + }); + + it('submits the normalized value through a hidden input', async () => { + const user = userEvent.setup(); + render( +
+ + , + ); + + await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '2025550123'); + + const form = screen.getByTestId('form'); + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected a form element'); + } + expect(new FormData(form).get('phoneNumber')).toBe('+12025550123'); + }); + + it('inherits Field state and associates its label and messages with the telephone input', () => { + render( + + Phone number + + Enter a valid phone number + , + ); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + expect(input).toBeDisabled(); + expect(input).toBeRequired(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAccessibleDescription('Enter a valid phone number'); + expect(screen.getByRole('button', { name: 'Country, United States' })).toBeDisabled(); + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-disabled', ''); + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-invalid', ''); + }); + + it.each(['sm', 'md', 'lg'] as const)('reflects the %s size', size => { + render( + , + ); + + expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-size', size); + }); + + it('supports controlled values', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + const input = screen.getByRole('textbox', { name: 'Phone number' }); + expect(input).toHaveValue('(202) 555-0123'); + + await user.type(input, '4'); + + expect(onValueChange).toHaveBeenLastCalledWith('+120255501234'); + expect(input).toHaveValue('(202) 555-0123'); + }); +}); diff --git a/packages/ui/src/mosaic/components/phone-input/phone-input.tsx b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx new file mode 100644 index 00000000000..b7252362ef6 --- /dev/null +++ b/packages/ui/src/mosaic/components/phone-input/phone-input.tsx @@ -0,0 +1,341 @@ +'use client'; + +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import type { CountryEntry, CountryIso } from '../../../elements/PhoneInput/countryCodeData'; +import { IsoToCountryMap } from '../../../elements/PhoneInput/countryCodeData'; +import { + extractDigits, + formatPhoneNumber, + getFlagEmojiFromCountryIso, + parsePhoneString, +} from '../../../utils/phoneUtils'; +import type { MosaicElementProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { reset } from '../../utils/reset.styles'; +import { Combobox } from '../combobox'; +import { Field } from '../field'; +import { useOptionalFieldContext } from '../field/field.context'; +import { Icon } from '../icon'; +import { Input } from '../input'; +import { InputGroup } from '../input-group'; +import { Popover } from '../popover'; +import { styles } from './phone-input.styles'; + +const countryOptions = [...IsoToCountryMap.values()]; + +function getCountry(iso: CountryIso | undefined): CountryEntry { + const country = iso ? IsoToCountryMap.get(iso) : undefined; + const fallback = IsoToCountryMap.get('us') ?? countryOptions[0]; + if (!fallback) { + throw new Error('PhoneInput requires at least one country'); + } + return country ?? fallback; +} + +function getInitialCountry(value: string | undefined, defaultCountry: CountryIso | undefined): CountryIso { + return value ? parsePhoneString(value).iso : getCountry(defaultCountry).iso; +} + +function getNationalNumber(value: string, country: CountryEntry): string { + const digits = extractDigits(value); + return digits.startsWith(country.code) ? digits.slice(country.code.length) : digits; +} + +function toE164(country: CountryEntry, nationalNumber: string): string { + const number = extractDigits(nationalNumber); + return number ? `+${country.code}${number}` : ''; +} + +export interface PhoneInputProps extends Omit< + MosaicElementProps<'input'>, + 'className' | 'style' | 'type' | 'size' | 'value' | 'defaultValue' | 'onChange' +> { + /** The normalized E.164 value. */ + value?: string; + /** The initial normalized E.164 value for an uncontrolled input. */ + defaultValue?: string; + /** Called with the normalized E.164 value whenever the number or country changes. */ + onValueChange?: (value: string) => void; + /** Controls the selected country independently when calling codes are ambiguous. */ + country?: CountryIso; + /** Initial country when neither `country` nor a phone number selects one. @default 'us' */ + defaultCountry?: CountryIso; + onCountryChange?: (country: CountryIso) => void; + size?: 'sm' | 'md' | 'lg'; + countrySearchPlaceholder?: string; + noResultsMessage?: string; + /** Applied to the grouped root. */ + className?: string; + /** Applied to the grouped root. */ + style?: React.CSSProperties; +} + +export const PhoneInput = React.forwardRef(function MosaicPhoneInput( + { + value: valueProp, + defaultValue = '', + onValueChange, + country: countryProp, + defaultCountry, + onCountryChange, + size = 'md', + countrySearchPlaceholder = 'Search country or code', + noResultsMessage = 'No countries found', + disabled: disabledProp, + required: requiredProp, + id, + name, + form, + autoComplete = 'tel-national', + inputMode = 'tel', + maxLength = 25, + spellCheck = false, + className, + style, + 'aria-invalid': ariaInvalidProp, + 'aria-labelledby': ariaLabelledBy, + 'aria-describedby': ariaDescribedBy, + ...inputProps + }, + forwardedRef, +) { + const field = useOptionalFieldContext(); + const disabled = disabledProp ?? field?.disabled ?? false; + const ariaInvalid = ariaInvalidProp ?? (field?.invalid ? true : undefined); + const invalid = ariaInvalid === true || ariaInvalid === 'true'; + const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue); + const value = valueProp ?? uncontrolledValue; + const [uncontrolledCountry, setUncontrolledCountry] = React.useState(() => + getInitialCountry(valueProp ?? defaultValue, defaultCountry), + ); + const country = getCountry(countryProp ?? uncontrolledCountry); + const nationalNumber = getNationalNumber(value, country); + const formattedNumber = formatPhoneNumber(nationalNumber, country.pattern, country.code); + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(''); + const inputRef = React.useRef(null); + const setInputRef = React.useCallback( + (node: HTMLInputElement | null) => { + inputRef.current = node; + if (typeof forwardedRef === 'function') { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }, + [forwardedRef], + ); + + React.useEffect(() => { + if (countryProp === undefined && valueProp) { + setUncontrolledCountry(parsePhoneString(valueProp).iso); + } + }, [countryProp, valueProp]); + + React.useEffect(() => { + if (!open) { + setQuery(''); + } + }, [open]); + + const filteredCountries = React.useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return countryOptions; + } + return countryOptions.filter(option => + `${option.name} ${option.iso} +${option.code}`.toLowerCase().includes(normalizedQuery), + ); + }, [query]); + + const setValue = React.useCallback( + (nextValue: string) => { + if (valueProp === undefined) { + setUncontrolledValue(nextValue); + } + onValueChange?.(nextValue); + }, + [onValueChange, valueProp], + ); + + const setCountry = React.useCallback( + (nextCountry: CountryEntry) => { + if (countryProp === undefined) { + setUncontrolledCountry(nextCountry.iso); + } + onCountryChange?.(nextCountry.iso); + setValue(toE164(nextCountry, nationalNumber)); + setOpen(false); + inputRef.current?.focus(); + }, + [countryProp, nationalNumber, onCountryChange, setValue], + ); + + const handleNumberChange = (event: React.ChangeEvent) => { + const nextValue = event.target.value; + if (nextValue.includes('+')) { + const parsed = parsePhoneString(nextValue); + const parsedCountry = getCountry(parsed.iso); + if (countryProp === undefined) { + setUncontrolledCountry(parsedCountry.iso); + } + onCountryChange?.(parsedCountry.iso); + setValue(toE164(parsedCountry, parsed.number)); + return; + } + setValue(toE164(country, nextValue)); + }; + + return ( + <> + + + + } + type='button' + disabled={disabled} + aria-label={`Country, ${country.name}`} + > + + + + + + { + const nextCountry = countryOptions.find(option => option.iso === iso); + if (nextCountry) { + setCountry(nextCountry); + } + }} + > + + + + + + + + + {filteredCountries.length > 0 ? ( + filteredCountries.map(option => ( + + + {option.name} + +{option.code} + + )) + ) : ( + {noResultsMessage} + )} + + + + + + + + + {name ? ( + + ) : null} + + ); +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 28695af5cfb..04cafd1ab4e 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -62,6 +62,8 @@ export { Input } from '../components/input'; export type { InputProps, InputVariant } from '../components/input'; export { InputGroup } from '../components/input-group'; export type { InputGroupActionProps, InputGroupRootProps, InputGroupTextProps } 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';