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

feat(react): introduce inputRef for focus management #32

Merged
merged 3 commits into from
Feb 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions packages/autocomplete-react/src/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsx h */

import { h } from 'preact';
import { useRef, useEffect } from 'preact/hooks';
import { useRef, useEffect, Ref } from 'preact/hooks';
import { createPortal } from 'preact/compat';

import {
Expand All @@ -25,10 +25,18 @@ interface PublicRendererProps {
* The dropdown placement related to the container.
*/
dropdownPlacement?: 'start' | 'end';
/**
* The ref to the input element.
*
* Useful for managing focus.
*/
inputRef?: Ref<HTMLInputElement | null>;
}

export interface RendererProps extends Required<PublicRendererProps> {
export interface RendererProps extends PublicRendererProps {
dropdownContainer: HTMLElement;
dropdownPlacement: 'start' | 'end';
inputRef?: Ref<HTMLInputElement | null>;
}

interface PublicProps<TItem>
Expand All @@ -47,6 +55,7 @@ export function getDefaultRendererProps<TItem>(
)
: autocompleteProps.environment.document.body,
dropdownPlacement: rendererProps.dropdownPlacement ?? 'start',
inputRef: rendererProps.inputRef,
};
}

Expand All @@ -56,15 +65,20 @@ export function Autocomplete<TItem extends {}>(
const {
dropdownContainer,
dropdownPlacement,
inputRef: providedInputRef,
...autocompleteProps
} = providedProps;
const props = getDefaultProps(autocompleteProps);
const rendererProps = getDefaultRendererProps(
{ dropdownContainer, dropdownPlacement },
{
dropdownContainer,
dropdownPlacement,
inputRef: providedInputRef,
},
props
);

const inputRef = useRef<HTMLInputElement | null>(null);
const inputRef = providedInputRef ?? useRef<HTMLInputElement | null>(null);
const searchBoxRef = useRef<HTMLFormElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);

Expand Down
189 changes: 189 additions & 0 deletions stories/display.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/** @jsx h */

import { h, render } from 'preact';
import { useState, useEffect, useRef, useCallback } from 'preact/hooks';
import { createPortal } from 'preact/compat';
import { storiesOf } from '@storybook/html';
import algoliasearch from 'algoliasearch/lite';

Expand Down Expand Up @@ -90,4 +92,191 @@ storiesOf('Display', module)
searchBoxPosition: 'end',
}
)
)
.add(
'Modal',
withPlayground(
({ container, dropdownContainer }) => {
function App() {
const modalRef = useRef(null);
const inputRef = useRef(null);
const [isShowing, setIsShowing] = useState(false);

const toggleModal = useCallback(() => {
if (isShowing) {
setIsShowing(false);
return;
}

setIsShowing(true);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it the part you said it won't focus without setTimeout?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, using the autoFocus prop triggers this message:

Autofocus processing was blocked because a document already has a focused element.

Focusing at the end of the event loop works though.

}, [isShowing, setIsShowing]);

useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (
(event.key === 'Escape' && isShowing) ||
(event.key === 'k' && (event.metaKey || event.ctrlKey))
) {
event.preventDefault();
toggleModal();
}
}

window.addEventListener('keydown', onKeyDown);

return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [toggleModal, isShowing]);

return (
<div>
<button
style={{
cursor: 'pointer',
background: '#fff',
border: '1px solid #ddd',
borderRadius: 6,
fontSize: 'inherit',
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: 180,
}}
onClick={toggleModal}
>
<div>
<svg
height="16"
viewBox="0 0 16 16"
width="16"
style={{ marginRight: 12, height: 12, width: 12 }}
>
<path
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"
fillRule="evenodd"
></path>
</svg>
Search
</div>

<kbd
style={{
border: '1px solid #ddd',
padding: '2px 4px',
borderRadius: 3,
background: '#f9f8f8',
}}
>
Cmd+K
</kbd>
</button>

{isShowing &&
createPortal(
<div
ref={modalRef}
onClick={event => {
if (event.target === modalRef.current) {
setIsShowing(false);
}
}}
style={{
display: 'flex',
paddingTop: 120,
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, .24)',
bottom: 0,
left: 0,
overflowY: 'auto',
position: 'fixed',
top: 0,
right: 0,
}}
>
<div
style={{
width: 480,
maxWidth: 'calc(100vw - 32px)',
height: 0,
}}
>
<Autocomplete
openOnFocus={true}
placeholder="Search..."
defaultHighlightedIndex={0}
inputRef={inputRef}
getSources={({ query }) => {
if (!query) {
return [
{
getInputValue({ suggestion }) {
return suggestion.query;
},
getSuggestions() {
return [
{
query: 'GitHub',
_highlightResult: {
query: { value: 'GitHub' },
},
},
{
query: 'Twitter',
_highlightResult: {
query: { value: 'Twitter' },
},
},
];
},
},
];
}

return [
{
getInputValue({ suggestion }) {
return suggestion.query;
},
getSuggestions({ query }) {
return getAlgoliaHits({
searchClient,
queries: [
{
indexName:
'instant_search_demo_query_suggestions',
query,
params: {
hitsPerPage: 4,
},
},
],
});
},
},
];
}}
/>
</div>
</div>,
dropdownContainer
)}
</div>
);
}

render(<App />, container);

return container;
},
{
searchBoxPosition: 'end',
}
)
);