From da78fff6d911c4c2e7d0c41d6bdb14b38218cef1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 21 Oct 2026 20:37:56 -0400 Subject: [PATCH 1/3] fix(mosaic): keep the phone input caret in place while formatting --- .changeset/mosaic-phone-input-caret.md | 2 + packages/mosaic/AGENTS.md | 1 + .../phone-input/phone-input.format.ts | 39 +++++++ .../phone-input/phone-input.test.tsx | 110 ++++++++++++++++++ .../components/phone-input/phone-input.tsx | 61 +++++----- .../__tests__/use-formatted-input.test.tsx | 102 ++++++++++++++++ packages/mosaic/src/primitives/hooks/index.ts | 1 + .../primitives/hooks/use-formatted-input.ts | 86 ++++++++++++++ 8 files changed, 369 insertions(+), 33 deletions(-) create mode 100644 .changeset/mosaic-phone-input-caret.md create mode 100644 packages/mosaic/src/components/phone-input/phone-input.format.ts create mode 100644 packages/mosaic/src/primitives/hooks/__tests__/use-formatted-input.test.tsx create mode 100644 packages/mosaic/src/primitives/hooks/use-formatted-input.ts 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..b9d1916d25a 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.test.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.test.tsx @@ -212,6 +212,116 @@ 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('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..a6a937442a8 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,6 +114,9 @@ export const PhoneInput = React.forwardRef(fu const mergedInputRef = useMergeRefs([inputRef, forwardedRef]); React.useEffect(() => { + if (valueProp === undefined || valueProp === emittedValueRef.current) { + return; + } if (countryProp === undefined && valueProp) { setUncontrolledCountry(parsePhoneString(valueProp).iso); } @@ -150,6 +140,7 @@ export const PhoneInput = React.forwardRef(fu const setValue = React.useCallback( (nextValue: string) => { + emittedValueRef.current = nextValue; if (valueProp === undefined) { setUncontrolledValue(nextValue); } @@ -171,20 +162,25 @@ export const PhoneInput = React.forwardRef(fu [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 ( <> @@ -318,7 +314,7 @@ export const PhoneInput = React.forwardRef(fu {...inputProps} id={id} type='tel' - value={formattedNumber} + {...numberInput} disabled={disabledProp} required={requiredProp} form={form} @@ -329,7 +325,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; +} From 2609fe445e04b99767a84a970877e0f220c5b466 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 21 Oct 2026 20:46:36 -0400 Subject: [PATCH 2/3] refactor(mosaic): clear the phone country search when the popup closes --- .../phone-input/phone-input.test.tsx | 25 +++++++++++++++++++ .../components/phone-input/phone-input.tsx | 14 +++++------ 2 files changed, 32 insertions(+), 7 deletions(-) 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 b9d1916d25a..e2d80d97154 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(); diff --git a/packages/mosaic/src/components/phone-input/phone-input.tsx b/packages/mosaic/src/components/phone-input/phone-input.tsx index a6a937442a8..f6396dcf3c0 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.tsx @@ -122,12 +122,6 @@ export const PhoneInput = React.forwardRef(fu } }, [countryProp, valueProp]); - React.useEffect(() => { - if (!open) { - setQuery(''); - } - }, [open]); - const filteredCountries = React.useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { @@ -157,6 +151,7 @@ export const PhoneInput = React.forwardRef(fu onCountryChange?.(nextCountry.iso); setValue(toE164(nextCountry, nationalNumber)); setOpen(false); + setQuery(''); inputRef.current?.focus(); }, [countryProp, nationalNumber, onCountryChange, setValue], @@ -194,7 +189,12 @@ export const PhoneInput = React.forwardRef(fu > { + setOpen(nextOpen); + if (!nextOpen) { + setQuery(''); + } + }} initialFocus='first' placement='bottom-start' sideOffset={8} From 616a6f524aadd4adae3a2a0b7549c3b5f05fd590 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 21 Oct 2026 21:07:24 -0400 Subject: [PATCH 3/3] fix(mosaic): follow an outside phone value that restores a typed number --- .../phone-input/phone-input.test.tsx | 36 +++++++++++++++++++ .../components/phone-input/phone-input.tsx | 4 ++- 2 files changed, 39 insertions(+), 1 deletion(-) 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 e2d80d97154..5643f0358a3 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.test.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.test.tsx @@ -347,6 +347,42 @@ describe('Mosaic PhoneInput', () => { 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 f6396dcf3c0..3ca0d4386cf 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.tsx @@ -114,7 +114,9 @@ export const PhoneInput = React.forwardRef(fu const mergedInputRef = useMergeRefs([inputRef, forwardedRef]); React.useEffect(() => { - if (valueProp === undefined || valueProp === emittedValueRef.current) { + const emittedValue = emittedValueRef.current; + emittedValueRef.current = null; + if (valueProp === undefined || valueProp === emittedValue) { return; } if (countryProp === undefined && valueProp) {