| 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 | } |