diff --git a/.changeset/mosaic-phone-input-caret.md b/.changeset/mosaic-phone-input-caret.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-phone-input-caret.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md
index f371588452c..a3598dff468 100644
--- a/packages/mosaic/AGENTS.md
+++ b/packages/mosaic/AGENTS.md
@@ -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` |
diff --git a/packages/mosaic/src/components/phone-input/phone-input.format.ts b/packages/mosaic/src/components/phone-input/phone-input.format.ts
new file mode 100644
index 00000000000..6de834aa5aa
--- /dev/null
+++ b/packages/mosaic/src/components/phone-input/phone-input.format.ts
@@ -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);
+}
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 ad010ece7bb..5643f0358a3 100644
--- a/packages/mosaic/src/components/phone-input/phone-input.test.tsx
+++ b/packages/mosaic/src/components/phone-input/phone-input.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
@@ -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(
+ ,
+ );
+
+ const input = screen.getByRole('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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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 (
+
+ );
+ }
+ render();
+
+ 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(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ 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 (
+ <>
+
+
+
+ >
+ );
+ }
+ render();
+
+ 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(
diff --git a/packages/mosaic/src/components/phone-input/phone-input.tsx b/packages/mosaic/src/components/phone-input/phone-input.tsx
index 0cb9bb5b7c1..3ca0d4386cf 100644
--- a/packages/mosaic/src/components/phone-input/phone-input.tsx
+++ b/packages/mosaic/src/components/phone-input/phone-input.tsx
@@ -1,19 +1,14 @@
'use client';
import type { CountryEntry, CountryIso } from '@clerk/shared/phone';
-import {
- extractDigits,
- formatPhoneNumber,
- getFlagEmojiFromCountryIso,
- IsoToCountryMap,
- parsePhoneString,
-} from '@clerk/shared/phone';
+import { extractDigits, getFlagEmojiFromCountryIso, IsoToCountryMap, parsePhoneString } from '@clerk/shared/phone';
import { useMergeRefs } from '@floating-ui/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';
import { ChevronDown } from '../../icons/glyphs/chevron-down';
import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass';
+import { useFormattedInput } from '../../primitives/hooks';
import type { MosaicElementProps } from '../../props';
import { mergeStyleProps, themeProps } from '../../props';
import { reset } from '../../styles/reset.styles';
@@ -24,6 +19,7 @@ import { useOptionalFieldContext } from '../field/field.context';
import { Icon } from '../icon';
import { InputGroup } from '../input-group';
import { Popover } from '../popover';
+import { formatNationalNumber, getNationalNumber, toE164 } from './phone-input.format';
import { styles } from './phone-input.styles';
const countryOptions = [...IsoToCountryMap.values()];
@@ -46,16 +42,6 @@ function getInitialCountry(value: string | undefined, defaultCountry: 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'>,
'type' | 'size' | 'value' | 'defaultValue' | 'onChange'
@@ -117,9 +103,10 @@ export const PhoneInput = React.forwardRef(fu
const [uncontrolledCountry, setUncontrolledCountry] = React.useState(() =>
getInitialCountry(valueProp ?? defaultValue, defaultCountry),
);
+ const emittedValueRef = React.useRef(null);
const country = getCountry(countryProp ?? uncontrolledCountry);
const nationalNumber = getNationalNumber(value, country);
- const formattedNumber = formatPhoneNumber(nationalNumber, country.pattern, country.code);
+ const formattedNumber = formatNationalNumber(nationalNumber, country.pattern);
const [open, setOpen] = React.useState(false);
const [anchor, setAnchor] = React.useState(null);
const [query, setQuery] = React.useState('');
@@ -127,17 +114,16 @@ export const PhoneInput = React.forwardRef(fu
const mergedInputRef = useMergeRefs([inputRef, forwardedRef]);
React.useEffect(() => {
+ const emittedValue = emittedValueRef.current;
+ emittedValueRef.current = null;
+ if (valueProp === undefined || valueProp === emittedValue) {
+ return;
+ }
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) {
@@ -150,6 +136,7 @@ export const PhoneInput = React.forwardRef(fu
const setValue = React.useCallback(
(nextValue: string) => {
+ emittedValueRef.current = nextValue;
if (valueProp === undefined) {
setUncontrolledValue(nextValue);
}
@@ -166,25 +153,31 @@ export const PhoneInput = React.forwardRef(fu
onCountryChange?.(nextCountry.iso);
setValue(toE164(nextCountry, nationalNumber));
setOpen(false);
+ setQuery('');
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);
+ const numberInput = useFormattedInput({
+ value: formattedNumber,
+ accept: /[\d+]/,
+ format: nextValue =>
+ nextValue.includes('+') ? nextValue : formatNationalNumber(extractDigits(nextValue), country.pattern),
+ onChange: nextValue => {
+ 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;
}
- onCountryChange?.(parsedCountry.iso);
- setValue(toE164(parsedCountry, parsed.number));
- return;
- }
- setValue(toE164(country, nextValue));
- };
+ setValue(toE164(country, nextValue));
+ },
+ });
return (
<>
@@ -198,7 +191,12 @@ export const PhoneInput = React.forwardRef(fu
>
{
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ setQuery('');
+ }
+ }}
initialFocus='first'
placement='bottom-start'
sideOffset={8}
@@ -318,7 +316,7 @@ export const PhoneInput = React.forwardRef(fu
{...inputProps}
id={id}
type='tel'
- value={formattedNumber}
+ {...numberInput}
disabled={disabledProp}
required={requiredProp}
form={form}
@@ -329,7 +327,6 @@ export const PhoneInput = React.forwardRef(fu
aria-invalid={ariaInvalidProp}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
- onChange={handleNumberChange}
/>
{name ? (
diff --git a/packages/mosaic/src/primitives/hooks/__tests__/use-formatted-input.test.tsx b/packages/mosaic/src/primitives/hooks/__tests__/use-formatted-input.test.tsx
new file mode 100644
index 00000000000..509f38d725a
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/__tests__/use-formatted-input.test.tsx
@@ -0,0 +1,102 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { useFormattedInput } from '../use-formatted-input';
+
+function groupThousands(raw: string): string {
+ return raw.replace(/\D/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',');
+}
+
+function fixedTwoDecimals(raw: string): string {
+ const [integer = '', fraction = ''] = raw.replace(/[^\d.]/g, '').split('.');
+ return `${Number(integer || '0')}.${fraction.padEnd(2, '0').slice(0, 2)}`;
+}
+
+function FormattedInput(props: { initial: string; format: (raw: string) => string; accept?: RegExp }) {
+ const [value, setValue] = React.useState(props.initial);
+ const input = useFormattedInput({
+ value,
+ onChange: setValue,
+ format: props.format,
+ accept: props.accept,
+ });
+ return (
+
+ );
+}
+
+async function editAt(initial: string, caret: number, keys: string, format = groupThousands, accept?: RegExp) {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+ const input = screen.getByRole('textbox', { name: 'Amount' });
+ await user.click(input);
+ input.setSelectionRange(caret, caret);
+ await user.keyboard(keys);
+ return input;
+}
+
+describe('useFormattedInput', () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('keeps the caret after a character inserted where separators move', async () => {
+ const input = await editAt('1,23467', 5, '5');
+
+ expect(input).toHaveValue('1,234,567');
+ expect(input.selectionStart).toBe(7);
+ });
+
+ it('keeps the caret at the end while typing at the end', async () => {
+ const input = await editAt('123', 3, '4');
+
+ expect(input).toHaveValue('1,234');
+ expect(input.selectionStart).toBe(5);
+ });
+
+ it('steps back over a separator on backspace', async () => {
+ const input = await editAt('1,234', 2, '{Backspace}');
+
+ expect(input).toHaveValue('1,234');
+ expect(input.selectionStart).toBe(1);
+ });
+
+ it('steps forward over a separator on delete', async () => {
+ const input = await editAt('1,234', 1, '{Delete}');
+
+ expect(input).toHaveValue('1,234');
+ expect(input.selectionStart).toBe(2);
+ });
+
+ it('keeps the caret in place when the edit is rejected', async () => {
+ const input = await editAt('1,234', 3, 'a');
+
+ expect(input).toHaveValue('1,234');
+ expect(input.selectionStart).toBe(3);
+ });
+
+ it('keeps the caret after the typed character when the formatter drops one before it', async () => {
+ const input = await editAt('0.00', 1, '1', fixedTwoDecimals, /[\d.]/);
+
+ expect(input).toHaveValue('1.00');
+ expect(input.selectionStart).toBe(1);
+ });
+
+ it('accepts a global accept pattern', async () => {
+ const input = await editAt('1,23467', 5, '5', groupThousands, /\d/g);
+
+ expect(input).toHaveValue('1,234,567');
+ expect(input.selectionStart).toBe(7);
+ });
+});
diff --git a/packages/mosaic/src/primitives/hooks/index.ts b/packages/mosaic/src/primitives/hooks/index.ts
index 21951fdd2e4..8ce698dbe90 100644
--- a/packages/mosaic/src/primitives/hooks/index.ts
+++ b/packages/mosaic/src/primitives/hooks/index.ts
@@ -12,6 +12,7 @@ export type {
UseDataTableReturn,
} from './use-data-table';
export { useControllableState } from './use-controllable-state';
+export { useFormattedInput, type UseFormattedInputOptions } from './use-formatted-input';
export { useLayoutAnimation } from './use-layout-animation';
export {
type TransitionProps,
diff --git a/packages/mosaic/src/primitives/hooks/use-formatted-input.ts b/packages/mosaic/src/primitives/hooks/use-formatted-input.ts
new file mode 100644
index 00000000000..28479f58461
--- /dev/null
+++ b/packages/mosaic/src/primitives/hooks/use-formatted-input.ts
@@ -0,0 +1,86 @@
+import React from 'react';
+
+interface PendingCaret {
+ input: HTMLInputElement;
+ acceptedBeforeCaret: string;
+ deleteForward: boolean;
+}
+
+export interface UseFormattedInputOptions {
+ value: string;
+ onChange: (value: string) => void;
+ format: (value: string) => string;
+ accept?: RegExp;
+}
+
+export interface FormattedInputProps {
+ value: string;
+ onChange: React.ChangeEventHandler;
+}
+
+export function useFormattedInput({
+ value,
+ onChange,
+ format,
+ accept = /\d/,
+}: UseFormattedInputOptions): FormattedInputProps {
+ const pendingRef = React.useRef(null);
+ const [, rerender] = React.useReducer((count: number) => count + 1, 0);
+
+ React.useLayoutEffect(() => {
+ const pending = pendingRef.current;
+ if (!pending) {
+ return;
+ }
+ pendingRef.current = null;
+ const caret = findCaret(pending.input.value, pending.acceptedBeforeCaret, accept, pending.deleteForward);
+ pending.input.setSelectionRange(caret, caret);
+ });
+
+ return {
+ value,
+ onChange: event => {
+ const input = event.currentTarget;
+ const rawValue = input.value;
+ const beforeCaret = rawValue.slice(0, input.selectionStart ?? rawValue.length);
+ pendingRef.current = {
+ input,
+ acceptedBeforeCaret: keepAccepted(beforeCaret, accept),
+ deleteForward: isDeleteForward(event.nativeEvent),
+ };
+ onChange(format(rawValue));
+ rerender();
+ },
+ };
+}
+
+function findCaret(value: string, acceptedBeforeCaret: string, accept: RegExp, deleteForward: boolean): number {
+ const accepted = [...value].flatMap((char, index) => (isAccepted(char, accept) ? [{ char, index }] : []));
+ let caret = 0;
+ let matched = 0;
+ for (const char of acceptedBeforeCaret) {
+ const next = accepted[matched];
+ if (next?.char === char) {
+ caret = next.index + 1;
+ matched++;
+ }
+ }
+ if (deleteForward) {
+ while (caret < value.length && !isAccepted(value.charAt(caret), accept)) {
+ caret++;
+ }
+ }
+ return caret;
+}
+
+function keepAccepted(value: string, accept: RegExp): string {
+ return [...value].filter(char => isAccepted(char, accept)).join('');
+}
+
+function isDeleteForward(event: Event): boolean {
+ return 'inputType' in event && event.inputType === 'deleteContentForward';
+}
+
+function isAccepted(char: string, accept: RegExp): boolean {
+ return char.search(accept) !== -1;
+}