SearchInput.js (8970B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ 4 5 import React, { Component } from "devtools/client/shared/vendor/react"; 6 import { 7 button, 8 div, 9 label, 10 input, 11 span, 12 } from "devtools/client/shared/vendor/react-dom-factories"; 13 import PropTypes from "devtools/client/shared/vendor/react-prop-types"; 14 import { connect } from "devtools/client/shared/vendor/react-redux"; 15 import { CloseButton } from "./Button/index"; 16 17 import DebuggerImage from "./DebuggerImage"; 18 import actions from "../../actions/index"; 19 import { getSearchOptions } from "../../selectors/index"; 20 21 const classnames = require("resource://devtools/client/shared/classnames.js"); 22 const SearchModifiers = require("resource://devtools/client/shared/components/SearchModifiers.js"); 23 24 const arrowBtn = (onClick, type, className, tooltip) => { 25 const props = { 26 className, 27 key: type, 28 onClick, 29 title: tooltip, 30 type, 31 }; 32 return button( 33 props, 34 React.createElement(DebuggerImage, { 35 name: type, 36 }) 37 ); 38 }; 39 40 export class SearchInput extends Component { 41 static defaultProps = { 42 expanded: false, 43 hasPrefix: false, 44 selectedItemId: "", 45 size: "", 46 showClose: true, 47 }; 48 49 constructor(props) { 50 super(props); 51 this.state = { 52 history: [], 53 excludePatterns: this.props.showSearchModifiers 54 ? props.searchOptions.excludePatterns 55 : null, 56 }; 57 } 58 59 static get propTypes() { 60 return { 61 count: PropTypes.number.isRequired, 62 expanded: PropTypes.bool.isRequired, 63 handleClose: PropTypes.func, 64 handleNext: PropTypes.func, 65 handlePrev: PropTypes.func, 66 hasPrefix: PropTypes.bool.isRequired, 67 isLoading: PropTypes.bool.isRequired, 68 onBlur: PropTypes.func, 69 onChange: PropTypes.func, 70 onFocus: PropTypes.func, 71 onHistoryScroll: PropTypes.func, 72 onKeyDown: PropTypes.func, 73 onKeyUp: PropTypes.func, 74 placeholder: PropTypes.string, 75 query: PropTypes.string, 76 selectedItemId: PropTypes.string, 77 shouldFocus: PropTypes.bool, 78 showClose: PropTypes.bool.isRequired, 79 showExcludePatterns: PropTypes.bool.isRequired, 80 excludePatternsLabel: PropTypes.string, 81 excludePatternsPlaceholder: PropTypes.string, 82 showErrorEmoji: PropTypes.bool.isRequired, 83 size: PropTypes.string, 84 disabled: PropTypes.bool, 85 summaryMsg: PropTypes.string, 86 searchKey: PropTypes.string.isRequired, 87 searchOptions: PropTypes.object, 88 setSearchOptions: PropTypes.func, 89 showSearchModifiers: PropTypes.bool.isRequired, 90 onToggleSearchModifier: PropTypes.func, 91 }; 92 } 93 94 componentDidMount() { 95 this.setFocus(); 96 } 97 98 componentDidUpdate(prevProps) { 99 if (this.props.shouldFocus && !prevProps.shouldFocus) { 100 this.setFocus(); 101 } 102 } 103 104 setFocus() { 105 if (this.$input) { 106 const _input = this.$input; 107 _input.focus(); 108 109 if (!_input.value) { 110 return; 111 } 112 113 // omit prefix @:# from being selected 114 const selectStartPos = this.props.hasPrefix ? 1 : 0; 115 _input.setSelectionRange(selectStartPos, _input.value.length + 1); 116 } 117 } 118 119 renderArrowButtons() { 120 const { handleNext, handlePrev } = this.props; 121 122 return [ 123 arrowBtn( 124 handlePrev, 125 "arrow-up", 126 classnames("nav-btn", "prev"), 127 L10N.getFormatStr("editor.searchResults.prevResult") 128 ), 129 arrowBtn( 130 handleNext, 131 "arrow-down", 132 classnames("nav-btn", "next"), 133 L10N.getFormatStr("editor.searchResults.nextResult") 134 ), 135 ]; 136 } 137 138 onFocus = e => { 139 const { onFocus } = this.props; 140 141 if (onFocus) { 142 onFocus(e); 143 } 144 }; 145 146 onBlur = e => { 147 const { onBlur } = this.props; 148 149 if (onBlur) { 150 onBlur(e); 151 } 152 }; 153 154 onKeyDown = e => { 155 const { onHistoryScroll, onKeyDown } = this.props; 156 if (!onHistoryScroll) { 157 onKeyDown(e); 158 return; 159 } 160 161 const inputValue = e.target.value; 162 const { history } = this.state; 163 const currentHistoryIndex = history.indexOf(inputValue); 164 165 if (e.key === "Enter") { 166 this.saveEnteredTerm(inputValue); 167 onKeyDown(e); 168 return; 169 } 170 171 if (e.key === "ArrowUp") { 172 const previous = 173 currentHistoryIndex > -1 ? currentHistoryIndex - 1 : history.length - 1; 174 const previousInHistory = history[previous]; 175 if (previousInHistory) { 176 e.preventDefault(); 177 onHistoryScroll(previousInHistory); 178 } 179 return; 180 } 181 182 if (e.key === "ArrowDown") { 183 const next = currentHistoryIndex + 1; 184 const nextInHistory = history[next]; 185 if (nextInHistory) { 186 onHistoryScroll(nextInHistory); 187 } 188 } 189 }; 190 191 onExcludeKeyDown = e => { 192 if (e.key === "Enter") { 193 this.props.setSearchOptions(this.props.searchKey, { 194 excludePatterns: this.state.excludePatterns, 195 }); 196 this.props.onKeyDown(e); 197 } 198 }; 199 200 saveEnteredTerm(query) { 201 const { history } = this.state; 202 const previousIndex = history.indexOf(query); 203 if (previousIndex !== -1) { 204 history.splice(previousIndex, 1); 205 } 206 history.push(query); 207 this.setState({ history }); 208 } 209 210 renderSummaryMsg() { 211 const { summaryMsg } = this.props; 212 213 if (!summaryMsg) { 214 return null; 215 } 216 return div( 217 { 218 className: "search-field-summary", 219 }, 220 summaryMsg 221 ); 222 } 223 224 renderSpinner() { 225 const { isLoading } = this.props; 226 if (!isLoading) { 227 return null; 228 } 229 return React.createElement(DebuggerImage, { 230 name: "loader", 231 className: "spin", 232 }); 233 } 234 235 renderNav() { 236 const { count, handleNext, handlePrev } = this.props; 237 if ((!handleNext && !handlePrev) || !count || count == 1) { 238 return null; 239 } 240 return div( 241 { 242 className: "search-nav-buttons", 243 }, 244 this.renderArrowButtons() 245 ); 246 } 247 248 renderSearchModifiers() { 249 if (!this.props.showSearchModifiers) { 250 return null; 251 } 252 return React.createElement(SearchModifiers, { 253 modifiers: this.props.searchOptions, 254 onToggleSearchModifier: updatedOptions => { 255 this.props.setSearchOptions(this.props.searchKey, updatedOptions); 256 this.props.onToggleSearchModifier(); 257 }, 258 }); 259 } 260 261 renderExcludePatterns() { 262 if (!this.props.showExcludePatterns) { 263 return null; 264 } 265 return div( 266 { 267 className: classnames("exclude-patterns-field", this.props.size), 268 }, 269 label(null, this.props.excludePatternsLabel), 270 input({ 271 placeholder: this.props.excludePatternsPlaceholder, 272 value: this.state.excludePatterns, 273 onKeyDown: this.onExcludeKeyDown, 274 onChange: e => 275 this.setState({ 276 excludePatterns: e.target.value, 277 }), 278 }) 279 ); 280 } 281 282 renderClose() { 283 if (!this.props.showClose) { 284 return null; 285 } 286 return React.createElement( 287 React.Fragment, 288 null, 289 span({ 290 className: "pipe-divider", 291 }), 292 React.createElement(CloseButton, { 293 handleClick: this.props.handleClose, 294 buttonClass: this.props.size, 295 }) 296 ); 297 } 298 299 render() { 300 const { 301 expanded, 302 onChange, 303 onKeyUp, 304 placeholder, 305 query, 306 selectedItemId, 307 showErrorEmoji, 308 size, 309 disabled, 310 } = this.props; 311 312 const inputProps = { 313 className: classnames({ 314 empty: showErrorEmoji, 315 }), 316 disabled, 317 onChange, 318 onKeyDown: e => this.onKeyDown(e), 319 onKeyUp, 320 onFocus: e => this.onFocus(e), 321 onBlur: e => this.onBlur(e), 322 "aria-autocomplete": "list", 323 "aria-controls": "result-list", 324 "aria-activedescendant": 325 expanded && selectedItemId ? `${selectedItemId}-title` : "", 326 placeholder, 327 value: query, 328 spellCheck: false, 329 ref: c => (this.$input = c), 330 }; 331 return div( 332 { 333 className: "search-outline", 334 }, 335 div( 336 { 337 className: classnames("search-field", size), 338 role: "combobox", 339 "aria-haspopup": "listbox", 340 "aria-owns": "result-list", 341 "aria-expanded": expanded, 342 }, 343 React.createElement(DebuggerImage, { 344 name: "search", 345 }), 346 input(inputProps), 347 this.renderSpinner(), 348 this.renderSummaryMsg(), 349 this.renderNav(), 350 div( 351 { 352 className: "search-buttons-bar", 353 }, 354 this.renderSearchModifiers(), 355 this.renderClose() 356 ) 357 ), 358 this.renderExcludePatterns() 359 ); 360 } 361 } 362 const mapStateToProps = (state, props) => ({ 363 searchOptions: getSearchOptions(state, props.searchKey), 364 }); 365 366 export default connect(mapStateToProps, { 367 setSearchOptions: actions.setSearchOptions, 368 })(SearchInput);