Skip to content

Commit a132960

Browse files
authored
fix: focus being lost when input value is set from external state (#51)
1 parent e396791 commit a132960

7 files changed

Lines changed: 289 additions & 4 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

src/index.test.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,64 @@ it('accepts origin and bias options', async () => {
378378
});
379379
});
380380

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+
});
438+
381439
it('disables browser autocomplete by default', () => {
382440
render(<AddressSearch locale="en-GB" apiKey="some-key" onSelect={vi.fn()} />);
383441

src/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import React, {
33
type ChangeEvent,
44
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;
@@ -143,7 +143,9 @@ function AddressSearch(props: Props): JSX.Element {
143143
const [value, setValue] = useState('');
144144
const [, setError] = useState(null);
145145

146-
const anchorRef = useRef<HTMLInputElement>(null);
146+
const { elementRef: anchorRef, preserveFocus } =
147+
usePreserveFocus<HTMLInputElement>();
148+
147149
const rect = anchorRef.current?.getBoundingClientRect();
148150

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

207+
// Custom Input components with external state management can cause DOM reconciliation issues that lose focus
208+
preserveFocus();
205209
setValue(search);
206210
}
207211

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',
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import React, { forwardRef, useState } from 'react';
3+
import AddressSearch, { type Address, type Item } from '..';
4+
5+
const meta: Meta = {
6+
title: 'Custom Components',
7+
component: AddressSearch,
8+
};
9+
10+
export default meta;
11+
12+
type Story = StoryObj<typeof AddressSearch>;
13+
14+
const CustomInput = forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
15+
({ value, onChange, ...rest }, ref) => {
16+
return (
17+
<input
18+
ref={ref}
19+
value={value || ''}
20+
onChange={onChange}
21+
style={{
22+
padding: '12px 16px',
23+
border: '2px solid #3b82f6',
24+
borderRadius: '8px',
25+
fontSize: '16px',
26+
width: '100%',
27+
outline: 'none',
28+
}}
29+
placeholder="Enter your address"
30+
{...rest}
31+
/>
32+
);
33+
}
34+
);
35+
36+
const CustomList = forwardRef<HTMLUListElement, React.ComponentProps<'ul'>>(
37+
(props, ref) => {
38+
return (
39+
<ul
40+
ref={ref}
41+
{...props}
42+
style={{
43+
...props.style,
44+
backgroundColor: '#f8fafc',
45+
border: '2px solid #3b82f6',
46+
borderRadius: '8px',
47+
listStyle: 'none',
48+
margin: 0,
49+
padding: 0,
50+
}}
51+
/>
52+
);
53+
}
54+
);
55+
56+
const CustomListItem = forwardRef<
57+
HTMLLIElement,
58+
React.ComponentProps<'li'> & { suggestion: Item; value?: string }
59+
>((props, ref) => {
60+
const {
61+
suggestion,
62+
onClick,
63+
onKeyDown,
64+
onMouseEnter,
65+
onMouseLeave,
66+
style,
67+
...rest
68+
} = props;
69+
return (
70+
<li
71+
ref={ref}
72+
{...rest}
73+
onClick={onClick}
74+
onKeyDown={(e) => {
75+
if (e.key === 'Enter' || e.key === ' ') {
76+
e.preventDefault();
77+
onClick?.(e as unknown as React.MouseEvent<HTMLLIElement>);
78+
}
79+
onKeyDown?.(e);
80+
}}
81+
style={{
82+
padding: '12px 16px',
83+
borderBottom: '1px solid #e2e8f0',
84+
cursor: 'pointer',
85+
backgroundColor: 'white',
86+
...style,
87+
}}
88+
onMouseEnter={(e) => {
89+
e.currentTarget.style.backgroundColor = '#eff6ff';
90+
onMouseEnter?.(e);
91+
}}
92+
onMouseLeave={(e) => {
93+
e.currentTarget.style.backgroundColor = 'white';
94+
onMouseLeave?.(e);
95+
}}
96+
>
97+
{suggestion.Text} {suggestion.Description}
98+
</li>
99+
);
100+
});
101+
102+
const Template = (props: React.ComponentProps<typeof AddressSearch>) => {
103+
const [result, setResult] = useState<Address>();
104+
105+
return (
106+
<>
107+
<AddressSearch {...props} onSelect={setResult} />
108+
<pre>{JSON.stringify(result, null, 2)}</pre>
109+
</>
110+
);
111+
};
112+
113+
export const CustomComponents: Story = {
114+
args: {
115+
// @ts-expect-error: env does exist
116+
// We need to prefix with STORYBOOK, otherwise Storybook will ignore the variable
117+
apiKey: import.meta.env.STORYBOOK_API_KEY ?? '',
118+
countries: ['US', 'GB'],
119+
locale: 'en_US',
120+
inline: true,
121+
components: {
122+
Input: CustomInput,
123+
List: CustomList,
124+
ListItem: CustomListItem,
125+
},
126+
},
127+
render: Template,
128+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { act, render, waitFor } from '@testing-library/react';
2+
import React, { useState } from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import usePreserveFocus from './usePreserveFocus';
5+
6+
describe('usePreserveFocus', () => {
7+
it('calls focus() asynchronously via Promise.resolve when element is focused', async () => {
8+
const mockFocus = vi.fn();
9+
10+
function TestComponent() {
11+
const [count, setCount] = useState(0);
12+
const { elementRef, preserveFocus } =
13+
usePreserveFocus<HTMLInputElement>();
14+
15+
React.useLayoutEffect(() => {
16+
if (elementRef.current) {
17+
elementRef.current.focus = mockFocus;
18+
Object.defineProperty(document, 'activeElement', {
19+
value: elementRef.current,
20+
writable: true,
21+
configurable: true,
22+
});
23+
}
24+
});
25+
26+
const handleClick = () => {
27+
preserveFocus();
28+
setCount(count + 1);
29+
};
30+
31+
return (
32+
<div>
33+
<input ref={elementRef} data-testid="test-input" />
34+
<button
35+
type="button"
36+
onClick={handleClick}
37+
data-testid="trigger-action"
38+
>
39+
Count: {count}
40+
</button>
41+
</div>
42+
);
43+
}
44+
45+
const { getByTestId } = render(<TestComponent />);
46+
const button = getByTestId('trigger-action');
47+
48+
act(() => {
49+
button.click();
50+
});
51+
52+
await waitFor(() => {
53+
expect(mockFocus).toHaveBeenCalledTimes(1);
54+
});
55+
});
56+
});

src/utils/usePreserveFocus.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useLayoutEffect, useRef, useState } from 'react';
2+
3+
function usePreserveFocus<T extends HTMLElement = HTMLElement>(): {
4+
elementRef: React.RefObject<T>;
5+
preserveFocus: () => void;
6+
} {
7+
const elementRef = useRef<T>(null);
8+
const [shouldRestoreFocus, setShouldRestoreFocus] = useState(false);
9+
10+
const preserveFocus = () => {
11+
if (elementRef.current && document.activeElement === elementRef.current) {
12+
setShouldRestoreFocus(true);
13+
}
14+
};
15+
16+
useLayoutEffect(() => {
17+
if (shouldRestoreFocus && elementRef.current) {
18+
// Use a microtask to ensure this runs after any DOM updates
19+
Promise.resolve().then(() => {
20+
if (elementRef.current) {
21+
elementRef.current.focus();
22+
}
23+
});
24+
setShouldRestoreFocus(false);
25+
}
26+
});
27+
28+
return {
29+
elementRef,
30+
preserveFocus,
31+
};
32+
}
33+
34+
export default usePreserveFocus;

0 commit comments

Comments
 (0)