main
js 225 lines 6.57 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import typeof {
11 SyntheticEvent,
12 SyntheticKeyboardEvent,
13 } from 'react-dom-bindings/src/events/SyntheticEvent';
14
15 import * as React from 'react';
16 import {useEffect, useRef, useState} from 'react';
17 import Button from './Button';
18 import ButtonIcon from './ButtonIcon';
19 import Icon from './Icon';
20 import type {IconType} from './Icon';
21 import AutoSizeInput from './Components/NativeStyleEditor/AutoSizeInput';
22
23 import styles from './SearchInput.css';
24
25 type Props = {
26 autoFocus?: boolean,
27 goToNextResult: () => void,
28 goToPreviousResult: () => void,
29 iconType?: IconType,
30 isPending?: boolean,
31 onClose?: () => void,
32 goToResult: (index: number) => void,
33 placeholder: string,
34 search: (text: string) => void,
35 searchIndex: number,
36 searchResultsCount: number,
37 searchText: string,
38 testName?: ?string,
39 };
40
41 export default function SearchInput({
42 autoFocus,
43 goToNextResult,
44 goToPreviousResult,
45 iconType = 'search',
46 isPending,
47 onClose,
48 goToResult,
49 placeholder,
50 search,
51 searchIndex,
52 searchResultsCount,
53 searchText,
54 testName,
55 }: Props): React.Node {
56 const inputRef = useRef<HTMLInputElement | null>(null);
57
58 const [indexDraft, setIndexDraft] = useState<string | null>(null);
59 const currentResultNumber = Math.min(searchIndex + 1, searchResultsCount);
60 const indexValue =
61 indexDraft !== null ? indexDraft : String(currentResultNumber);
62
63 const handleIndexChange = (event: SyntheticEvent) => {
64 // Only digits are meaningful here; strip anything else as it's typed.
65 const raw = event.currentTarget.value.replace(/[^0-9]/g, '');
66
67 if (raw === '' || searchResultsCount === 0) {
68 setIndexDraft(raw);
69 return;
70 }
71
72 // Clamp into [1, searchResultsCount] so the field never displays an
73 // out-of-range value, then live-preview by scrolling to that result.
74 const clamped = Math.max(
75 1,
76 Math.min(parseInt(raw, 10), searchResultsCount),
77 );
78 setIndexDraft(String(clamped));
79 goToResult(clamped - 1);
80 };
81 const handleIndexBlur = () => setIndexDraft(null);
82 const handleIndexKeyDown = (event: SyntheticKeyboardEvent) => {
83 if (event.key === 'Enter' || event.key === 'Escape') {
84 event.preventDefault();
85 event.currentTarget.blur();
86 }
87 };
88
89 const resetSearch = () => search('');
90
91 // $FlowFixMe[missing-local-annot]
92 const handleChange = ({currentTarget}) => {
93 search(currentTarget.value);
94 };
95 // $FlowFixMe[missing-local-annot]
96 const handleKeyPress = ({key, shiftKey}) => {
97 if (key === 'Enter') {
98 if (shiftKey) {
99 goToPreviousResult();
100 } else {
101 goToNextResult();
102 }
103 }
104 };
105
106 // Auto-focus search input
107 useEffect(() => {
108 const input = inputRef.current;
109 if (input === null) {
110 return () => {};
111 }
112
113 if (autoFocus) {
114 input.focus();
115 }
116
117 const handleKeyDown = (event: KeyboardEvent) => {
118 const {key, metaKey} = event;
119 if (key === 'f' && metaKey) {
120 input.focus();
121 event.preventDefault();
122 event.stopPropagation();
123 }
124 };
125
126 // It's important to listen to the ownerDocument to support the browser extension.
127 // Here we use portals to render individual tabs (e.g. Profiler),
128 // and the root document might belong to a different window.
129 const ownerDocumentElement = input.ownerDocument.documentElement;
130 if (ownerDocumentElement === null) {
131 return;
132 }
133 ownerDocumentElement.addEventListener('keydown', handleKeyDown);
134
135 return () =>
136 ownerDocumentElement.removeEventListener('keydown', handleKeyDown);
137 }, [autoFocus]);
138
139 return (
140 <div className={styles.SearchInput} data-testname={testName}>
141 <Icon className={styles.InputIcon} type={iconType} />
142 <input
143 data-testname={testName ? `${testName}-Input` : undefined}
144 className={styles.Input}
145 onChange={handleChange}
146 onKeyPress={handleKeyPress}
147 placeholder={placeholder}
148 ref={inputRef}
149 value={searchText}
150 />
151 {isPending === true && (
152 <span
153 className={styles.Spinner}
154 data-testname={testName ? `${testName}-Spinner` : undefined}
155 title="Searching…"
156 />
157 )}
158 {!!searchText && (
159 <React.Fragment>
160 <span
161 className={styles.IndexLabel}
162 data-testname={testName ? `${testName}-ResultsCount` : undefined}>
163 <AutoSizeInput
164 className={styles.IndexInput}
165 testName={testName ? `${testName}-ResultIndexInput` : undefined}
166 type="text"
167 inputMode="numeric"
168 pattern="[0-9]*"
169 aria-label="Go to search result number"
170 title="Go to search result number"
171 disabled={searchResultsCount === 0}
172 onBlur={handleIndexBlur}
173 onChange={handleIndexChange}
174 onKeyDown={handleIndexKeyDown}
175 value={indexValue}
176 />
177 {' | '}
178 {searchResultsCount}
179 </span>
180 <div className={styles.LeftVRule} />
181 <Button
182 data-testname={testName ? `${testName}-PreviousButton` : undefined}
183 disabled={!searchText}
184 onClick={goToPreviousResult}
185 title={
186 <React.Fragment>
187 Scroll to previous search result (<kbd>Shift</kbd> +{' '}
188 <kbd>Enter</kbd>)
189 </React.Fragment>
190 }>
191 <ButtonIcon type="up" />
192 </Button>
193 <Button
194 data-testname={testName ? `${testName}-NextButton` : undefined}
195 disabled={!searchText}
196 onClick={goToNextResult}
197 title={
198 <React.Fragment>
199 Scroll to next search result (<kbd>Enter</kbd>)
200 </React.Fragment>
201 }>
202 <ButtonIcon type="down" />
203 </Button>
204 {onClose == null && (
205 <Button
206 data-testname={testName ? `${testName}-ResetButton` : undefined}
207 disabled={!searchText}
208 onClick={resetSearch}
209 title="Reset search">
210 <ButtonIcon type="close" />
211 </Button>
212 )}
213 </React.Fragment>
214 )}
215 {onClose != null && (
216 <Button
217 data-testname={testName ? `${testName}-CloseButton` : undefined}
218 onClick={onClose}
219 title="Close search (Esc)">
220 <ButtonIcon type="close" />
221 </Button>
222 )}
223 </div>
224 );
225 }