Skip to content
This repository was archived by the owner on Jun 11, 2021. It is now read-only.

Commit 4d804fe

Browse files
feat(react): introduce inputRef for focus management (#32)
1 parent 553ea68 commit 4d804fe

2 files changed

Lines changed: 207 additions & 4 deletions

File tree

packages/autocomplete-react/src/Autocomplete.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @jsx h */
22

33
import { h } from 'preact';
4-
import { useRef, useEffect } from 'preact/hooks';
4+
import { useRef, useEffect, Ref } from 'preact/hooks';
55
import { createPortal } from 'preact/compat';
66

77
import {
@@ -25,10 +25,18 @@ interface PublicRendererProps {
2525
* The dropdown placement related to the container.
2626
*/
2727
dropdownPlacement?: 'start' | 'end';
28+
/**
29+
* The ref to the input element.
30+
*
31+
* Useful for managing focus.
32+
*/
33+
inputRef?: Ref<HTMLInputElement | null>;
2834
}
2935

30-
export interface RendererProps extends Required<PublicRendererProps> {
36+
export interface RendererProps extends PublicRendererProps {
3137
dropdownContainer: HTMLElement;
38+
dropdownPlacement: 'start' | 'end';
39+
inputRef?: Ref<HTMLInputElement | null>;
3240
}
3341

3442
interface PublicProps<TItem>
@@ -47,6 +55,7 @@ export function getDefaultRendererProps<TItem>(
4755
)
4856
: autocompleteProps.environment.document.body,
4957
dropdownPlacement: rendererProps.dropdownPlacement ?? 'start',
58+
inputRef: rendererProps.inputRef,
5059
};
5160
}
5261

@@ -56,15 +65,20 @@ export function Autocomplete<TItem extends {}>(
5665
const {
5766
dropdownContainer,
5867
dropdownPlacement,
68+
inputRef: providedInputRef,
5969
...autocompleteProps
6070
} = providedProps;
6171
const props = getDefaultProps(autocompleteProps);
6272
const rendererProps = getDefaultRendererProps(
63-
{ dropdownContainer, dropdownPlacement },
73+
{
74+
dropdownContainer,
75+
dropdownPlacement,
76+
inputRef: providedInputRef,
77+
},
6478
props
6579
);
6680

67-
const inputRef = useRef<HTMLInputElement | null>(null);
81+
const inputRef = providedInputRef ?? useRef<HTMLInputElement | null>(null);
6882
const searchBoxRef = useRef<HTMLFormElement | null>(null);
6983
const dropdownRef = useRef<HTMLDivElement | null>(null);
7084

stories/display.stories.tsx

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/** @jsx h */
22

33
import { h, render } from 'preact';
4+
import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
5+
import { createPortal } from 'preact/compat';
46
import { storiesOf } from '@storybook/html';
57
import algoliasearch from 'algoliasearch/lite';
68

@@ -90,4 +92,191 @@ storiesOf('Display', module)
9092
searchBoxPosition: 'end',
9193
}
9294
)
95+
)
96+
.add(
97+
'Modal',
98+
withPlayground(
99+
({ container, dropdownContainer }) => {
100+
function App() {
101+
const modalRef = useRef(null);
102+
const inputRef = useRef(null);
103+
const [isShowing, setIsShowing] = useState(false);
104+
105+
const toggleModal = useCallback(() => {
106+
if (isShowing) {
107+
setIsShowing(false);
108+
return;
109+
}
110+
111+
setIsShowing(true);
112+
setTimeout(() => {
113+
if (inputRef.current) {
114+
inputRef.current.focus();
115+
}
116+
}, 0);
117+
}, [isShowing, setIsShowing]);
118+
119+
useEffect(() => {
120+
function onKeyDown(event: KeyboardEvent) {
121+
if (
122+
(event.key === 'Escape' && isShowing) ||
123+
(event.key === 'k' && (event.metaKey || event.ctrlKey))
124+
) {
125+
event.preventDefault();
126+
toggleModal();
127+
}
128+
}
129+
130+
window.addEventListener('keydown', onKeyDown);
131+
132+
return () => {
133+
window.removeEventListener('keydown', onKeyDown);
134+
};
135+
}, [toggleModal, isShowing]);
136+
137+
return (
138+
<div>
139+
<button
140+
style={{
141+
cursor: 'pointer',
142+
background: '#fff',
143+
border: '1px solid #ddd',
144+
borderRadius: 6,
145+
fontSize: 'inherit',
146+
padding: '6px 12px',
147+
display: 'flex',
148+
alignItems: 'center',
149+
justifyContent: 'space-between',
150+
width: 180,
151+
}}
152+
onClick={toggleModal}
153+
>
154+
<div>
155+
<svg
156+
height="16"
157+
viewBox="0 0 16 16"
158+
width="16"
159+
style={{ marginRight: 12, height: 12, width: 12 }}
160+
>
161+
<path
162+
d="M12.6 11.2c.037.028.073.059.107.093l3 3a1 1 0 1 1-1.414 1.414l-3-3a1.009 1.009 0 0 1-.093-.107 7 7 0 1 1 1.4-1.4zM7 12A5 5 0 1 0 7 2a5 5 0 0 0 0 10z"
163+
fillRule="evenodd"
164+
></path>
165+
</svg>
166+
Search
167+
</div>
168+
169+
<kbd
170+
style={{
171+
border: '1px solid #ddd',
172+
padding: '2px 4px',
173+
borderRadius: 3,
174+
background: '#f9f8f8',
175+
}}
176+
>
177+
Cmd+K
178+
</kbd>
179+
</button>
180+
181+
{isShowing &&
182+
createPortal(
183+
<div
184+
ref={modalRef}
185+
onClick={event => {
186+
if (event.target === modalRef.current) {
187+
setIsShowing(false);
188+
}
189+
}}
190+
style={{
191+
display: 'flex',
192+
paddingTop: 120,
193+
justifyContent: 'center',
194+
backgroundColor: 'rgba(0, 0, 0, .24)',
195+
bottom: 0,
196+
left: 0,
197+
overflowY: 'auto',
198+
position: 'fixed',
199+
top: 0,
200+
right: 0,
201+
}}
202+
>
203+
<div
204+
style={{
205+
width: 480,
206+
maxWidth: 'calc(100vw - 32px)',
207+
height: 0,
208+
}}
209+
>
210+
<Autocomplete
211+
openOnFocus={true}
212+
placeholder="Search..."
213+
defaultHighlightedIndex={0}
214+
inputRef={inputRef}
215+
getSources={({ query }) => {
216+
if (!query) {
217+
return [
218+
{
219+
getInputValue({ suggestion }) {
220+
return suggestion.query;
221+
},
222+
getSuggestions() {
223+
return [
224+
{
225+
query: 'GitHub',
226+
_highlightResult: {
227+
query: { value: 'GitHub' },
228+
},
229+
},
230+
{
231+
query: 'Twitter',
232+
_highlightResult: {
233+
query: { value: 'Twitter' },
234+
},
235+
},
236+
];
237+
},
238+
},
239+
];
240+
}
241+
242+
return [
243+
{
244+
getInputValue({ suggestion }) {
245+
return suggestion.query;
246+
},
247+
getSuggestions({ query }) {
248+
return getAlgoliaHits({
249+
searchClient,
250+
queries: [
251+
{
252+
indexName:
253+
'instant_search_demo_query_suggestions',
254+
query,
255+
params: {
256+
hitsPerPage: 4,
257+
},
258+
},
259+
],
260+
});
261+
},
262+
},
263+
];
264+
}}
265+
/>
266+
</div>
267+
</div>,
268+
dropdownContainer
269+
)}
270+
</div>
271+
);
272+
}
273+
274+
render(<App />, container);
275+
276+
return container;
277+
},
278+
{
279+
searchBoxPosition: 'end',
280+
}
281+
)
93282
);

0 commit comments

Comments
 (0)