Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-phone-input-caret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) |
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) |
| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) |
| Styles several components share | `src/styles/*.styles.ts` |

Expand Down
39 changes: 39 additions & 0 deletions packages/mosaic/src/components/phone-input/phone-input.format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { CountryEntry } from '@clerk/shared/phone';
import { extractDigits } from '@clerk/shared/phone';

const E164_MAX_DIGITS = 15;

export function getNationalNumber(value: string, country: CountryEntry): string {
const digits = extractDigits(value);
const national = digits.startsWith(country.code) ? digits.slice(country.code.length) : digits;
return capNationalNumber(national, country);
}

export function toE164(country: CountryEntry, input: string): string {
const number = capNationalNumber(extractDigits(input), country);
return number ? `+${country.code}${number}` : '';
}

export function formatNationalNumber(digits: string, pattern: string | undefined): string {
if (!pattern || digits.length <= 3) {
return digits;
}
let formatted = '';
let next = 0;
for (const char of pattern) {
if (next === digits.length) {
break;
}
if (char === '.') {
formatted += digits.charAt(next);
next++;
} else {
formatted += char;
}
}
return formatted + digits.slice(next);
}

function capNationalNumber(digits: string, country: CountryEntry): string {
return digits.slice(0, E164_MAX_DIGITS - country.code.length);
}
171 changes: 171 additions & 0 deletions packages/mosaic/src/components/phone-input/phone-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,31 @@ describe('Mosaic PhoneInput', () => {
expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveFocus();
});

it('clears the country search when the popup is dismissed', async () => {
const user = userEvent.setup();
render(<PhoneInput aria-label='Phone number' />);

await user.click(screen.getByRole('button', { name: 'Country, United States' }));
await user.type(screen.getByRole('combobox', { name: 'Search countries' }), 'Greece');
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' }));

expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveValue('');
});

it('clears the country search after a country is chosen', async () => {
const user = userEvent.setup();
render(<PhoneInput aria-label='Phone number' />);

await user.click(screen.getByRole('button', { name: 'Country, United States' }));
await user.type(screen.getByRole('combobox', { name: 'Search countries' }), 'Greece');
await user.keyboard('{ArrowDown}{Enter}');
await user.click(screen.getByRole('button', { name: 'Country, Greece' }));

expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveValue('');
});

it('renders the country list inside the popup', async () => {
const user = userEvent.setup();
render(<PhoneInput aria-label='Phone number' />);
Expand Down Expand Up @@ -212,6 +237,152 @@ describe('Mosaic PhoneInput', () => {
expect(onValueChange).toHaveBeenLastCalledWith('+306901234567');
});

it('keeps the caret after a digit inserted mid-number', async () => {
const user = userEvent.setup();
render(
<PhoneInput
aria-label='Phone number'
defaultValue='+1202555012'
/>,
);

const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'Phone number' });
await user.click(input);
input.setSelectionRange(1, 1);
await user.keyboard('9');

expect(input).toHaveValue('(920) 255-5012');
expect(input.selectionStart).toBe(2);
});

it('stores only the digits it displays', async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<PhoneInput
aria-label='Phone number'
onValueChange={onValueChange}
/>,
);

const input = screen.getByRole('textbox', { name: 'Phone number' });
await user.type(input, '2025550123456789');

expect(input).toHaveValue('(202) 555-01234567');
expect(onValueChange).toHaveBeenLastCalledWith('+120255501234567');
});

it('caps a number without a pattern at the E.164 length', async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<PhoneInput
aria-label='Phone number'
defaultCountry='af'
onValueChange={onValueChange}
/>,
);

const input = screen.getByRole('textbox', { name: 'Phone number' });
await user.type(input, '1234567890123456');

expect(input).toHaveValue('1234567890123');
expect(onValueChange).toHaveBeenLastCalledWith('+931234567890123');
});

it('keeps a chosen country that shares its calling code while a controlled number is typed', async () => {
const user = userEvent.setup();
function ControlledPhoneInput() {
const [value, setValue] = React.useState('');
return (
<PhoneInput
aria-label='Phone number'
defaultCountry='ca'
value={value}
onValueChange={setValue}
/>
);
}
render(<ControlledPhoneInput />);

await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '6045');

expect(screen.getByRole('button', { name: 'Country, Canada' })).toBeInTheDocument();
});

it('follows an outside value from another calling code', () => {
const { rerender } = render(
<PhoneInput
aria-label='Phone number'
value='+12025550123'
/>,
);

rerender(
<PhoneInput
aria-label='Phone number'
value='+306901234567'
/>,
);

expect(screen.getByRole('button', { name: 'Country, Greece' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveValue('690 1234567');
});

it('follows an outside value from another country with the same calling code', () => {
const { rerender } = render(
<PhoneInput
aria-label='Phone number'
value='+12025550123'
/>,
);

rerender(
<PhoneInput
aria-label='Phone number'
value='+16045551234'
/>,
);

expect(screen.getByRole('button', { name: 'Country, Canada' })).toBeInTheDocument();
});

it('follows an outside value that restores a number typed earlier', async () => {
const user = userEvent.setup();
function ControlledPhoneInput() {
const [value, setValue] = React.useState('');
return (
<>
<PhoneInput
aria-label='Phone number'
value={value}
onValueChange={setValue}
/>
<button
type='button'
onClick={() => setValue('+306901234567')}
>
Use Greek number
</button>
<button
type='button'
onClick={() => setValue('+12025550123')}
>
Use US number
</button>
</>
);
}
render(<ControlledPhoneInput />);

await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '2025550123');
await user.click(screen.getByRole('button', { name: 'Use Greek number' }));
await user.click(screen.getByRole('button', { name: 'Use US number' }));

expect(screen.getByRole('button', { name: 'Country, United States' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveValue('(202) 555-0123');
});

it('submits the normalized value through a hidden input', async () => {
const user = userEvent.setup();
render(
Expand Down
Loading
Loading