summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/components/SearchBoxAutocompletePopup.js
blob: 08aad18872fe5c7bd542ddccfcb41de89a913c06 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const {
  Component,
} = require("resource://devtools/client/shared/vendor/react.js");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");

class SearchBoxAutocompletePopup extends Component {
  static get propTypes() {
    return {
      /**
       * autocompleteProvider takes search-box's entire input text as `filter` argument
       * ie. "is:cached pr"
       * returned value is array of objects like below
       * [{value: "is:cached protocol", displayValue: "protocol"}[, ...]]
       * `value` is used to update the search-box input box for given item
       * `displayValue` is used to render the autocomplete list
       */
      autocompleteProvider: PropTypes.func.isRequired,
      filter: PropTypes.string.isRequired,
      onItemSelected: PropTypes.func.isRequired,
    };
  }

  constructor(props, context) {
    super(props, context);
    this.state = this.computeState(props);
    this.computeState = this.computeState.bind(this);
    this.jumpToTop = this.jumpToTop.bind(this);
    this.jumpToBottom = this.jumpToBottom.bind(this);
    this.jumpBy = this.jumpBy.bind(this);
    this.select = this.select.bind(this);
    this.onMouseDown = this.onMouseDown.bind(this);
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps(nextProps) {
    if (this.props.filter === nextProps.filter) {
      return;
    }
    this.setState(this.computeState(nextProps));
  }

  componentDidUpdate() {
    if (this.refs.selected) {
      this.refs.selected.scrollIntoView(false);
    }
  }

  computeState({ autocompleteProvider, filter }) {
    const list = autocompleteProvider(filter);
    const selectedIndex = list.length ? 0 : -1;

    return { list, selectedIndex };
  }

  /**
   * Use this method to select the top-most item
   * This method is public, called outside of the autocomplete-popup component.
   */
  jumpToTop() {
    this.setState({ selectedIndex: 0 });
  }

  /**
   * Use this method to select the bottom-most item
   * This method is public.
   */
  jumpToBottom() {
    this.setState({ selectedIndex: this.state.list.length - 1 });
  }

  /**
   * Increment the selected index with the provided increment value. Will cycle to the
   * beginning/end of the list if the index exceeds the list boundaries.
   * This method is public.
   *
   * @param {number} increment - No. of hops in the direction
   */
  jumpBy(increment = 1) {
    const { list, selectedIndex } = this.state;
    let nextIndex = selectedIndex + increment;
    if (increment > 0) {
      // Positive cycling
      nextIndex = nextIndex > list.length - 1 ? 0 : nextIndex;
    } else if (increment < 0) {
      // Inverse cycling
      nextIndex = nextIndex < 0 ? list.length - 1 : nextIndex;
    }
    this.setState({ selectedIndex: nextIndex });
  }

  /**
   * Submit the currently selected item to the onItemSelected callback
   * This method is public.
   */
  select() {
    if (this.refs.selected) {
      this.props.onItemSelected(this.refs.selected.dataset.value);
    }
  }

  onMouseDown(e) {
    e.preventDefault();
    this.setState(
      { selectedIndex: Number(e.target.dataset.index) },
      this.select
    );
  }

  render() {
    const { list } = this.state;

    return (
      !!list.length &&
      dom.div(
        { className: "devtools-autocomplete-popup devtools-monospace" },
        dom.ul(
          { className: "devtools-autocomplete-listbox" },
          list.map((item, i) => {
            const isSelected = this.state.selectedIndex == i;
            const itemClassList = ["autocomplete-item"];

            if (isSelected) {
              itemClassList.push("autocomplete-selected");
            }
            return dom.li(
              {
                key: i,
                "data-index": i,
                "data-value": item.value,
                className: itemClassList.join(" "),
                ref: isSelected ? "selected" : null,
                onMouseDown: this.onMouseDown,
              },
              item.displayValue
            );
          })
        )
      )
    );
  }
}

module.exports = SearchBoxAutocompletePopup;