Skip to content

Commit ccc5ffc

Browse files
committed
fix: focus being lost when input value is set from external state
1 parent ff03fa7 commit ccc5ffc

8 files changed

Lines changed: 291 additions & 112 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"react-loqate": patch
3+
---
4+
5+
- Fix focus being lost when input value is set from external state

pnpm-lock.yaml

Lines changed: 0 additions & 106 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,61 @@ it('accepts origin and bias options', async () => {
377377
text: 'a',
378378
});
379379
});
380+
381+
it('preserves focus when using custom Input with external state management', async () => {
382+
function TestComponent() {
383+
const [, setExternalState] = React.useState('');
384+
385+
return (
386+
<AddressSearch
387+
locale="en-GB"
388+
apiKey="test-key"
389+
onSelect={() => {}}
390+
components={{
391+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
392+
Input: React.forwardRef<HTMLInputElement, any>(
393+
({ value, onChange, ...rest }, ref) => {
394+
React.useEffect(() => {
395+
setExternalState(value || '');
396+
}, [value]);
397+
398+
return (
399+
<input
400+
ref={ref}
401+
value={value || ''}
402+
onChange={(event) => {
403+
onChange?.(event);
404+
setExternalState(event.target.value);
405+
}}
406+
{...rest}
407+
data-testid="external-state-input"
408+
/>
409+
);
410+
}
411+
),
412+
}}
413+
/>
414+
);
415+
}
416+
417+
render(<TestComponent />);
418+
419+
const input = screen.getByTestId('external-state-input') as HTMLInputElement;
420+
input.focus();
421+
expect(document.activeElement).toBe(input);
422+
423+
fireEvent.change(input, { target: { value: 'a' } });
424+
425+
await screen.findByRole('list');
426+
427+
await waitFor(
428+
() => {
429+
const currentInput = screen.getByTestId('external-state-input');
430+
expect(document.activeElement).toBe(currentInput);
431+
},
432+
{ timeout: 1000 }
433+
);
434+
435+
const suggestions = screen.getAllByRole('listitem');
436+
expect(suggestions.length).toBeGreaterThan(0);
437+
});

src/index.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import clsx from 'clsx';
22
import React, {
3-
ChangeEvent,
4-
ComponentType,
3+
type ChangeEvent,
4+
type ComponentType,
55
useMemo,
6-
useRef,
76
useState,
87
} from 'react';
98
import DefaultInput from './components/DefaultInput';
@@ -13,6 +12,7 @@ import ClickAwayListener from './utils/ClickAwayListener';
1312
import Loqate from './utils/Loqate';
1413
import Portal from './utils/Portal';
1514
import useDebounceEffect from './utils/useDebounceEffect';
15+
import usePreserveFocus from './utils/usePreserveFocus';
1616

1717
export interface Props {
1818
locale: string;
@@ -141,7 +141,9 @@ function AddressSearch(props: Props): JSX.Element {
141141
const [value, setValue] = useState('');
142142
const [, setError] = useState(null);
143143

144-
const anchorRef = useRef<HTMLInputElement>(null);
144+
const { elementRef: anchorRef, preserveFocus } =
145+
usePreserveFocus<HTMLInputElement>();
146+
145147
const rect = anchorRef.current?.getBoundingClientRect();
146148

147149
async function find(text: string, containerId?: string): Promise<Item[]> {
@@ -200,6 +202,8 @@ function AddressSearch(props: Props): JSX.Element {
200202
}: ChangeEvent<HTMLInputElement>): Promise<void> {
201203
const { value: search } = target;
202204

205+
// Custom Input components with external state management can cause DOM reconciliation issues that lose focus
206+
preserveFocus();
203207
setValue(search);
204208
}
205209

src/stories/AddressSearch.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Meta, StoryObj } from '@storybook/react';
1+
import type { Meta, StoryObj } from '@storybook/react';
22
import React, { useState } from 'react';
3-
import AddressSearch, { Address, Props } from '..';
3+
import AddressSearch, { type Address, type Props } from '..';
44

55
const meta: Meta = {
66
title: 'Loqate Address Search',

0 commit comments

Comments
 (0)