tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

List.js (9498B)


      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 "use strict";
      6 
      7 const {
      8  createFactory,
      9  createRef,
     10  Component,
     11  cloneElement,
     12 } = require("resource://devtools/client/shared/vendor/react.mjs");
     13 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
     14 const {
     15  ul,
     16  li,
     17  div,
     18 } = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     19 
     20 const { scrollIntoView } = ChromeUtils.importESModule(
     21  "resource://devtools/client/shared/scroll.mjs"
     22 );
     23 const {
     24  preventDefaultAndStopPropagation,
     25 } = require("resource://devtools/client/shared/events.js");
     26 
     27 const lazy = {};
     28 ChromeUtils.defineESModuleGetters(lazy, {
     29  wrapMoveFocus: "resource://devtools/client/shared/focus.mjs",
     30  getFocusableElements: "resource://devtools/client/shared/focus.mjs",
     31 });
     32 
     33 class ListItemClass extends Component {
     34  static get propTypes() {
     35    return {
     36      active: PropTypes.bool,
     37      current: PropTypes.bool,
     38      onClick: PropTypes.func,
     39      item: PropTypes.shape({
     40        key: PropTypes.string,
     41        component: PropTypes.object,
     42        componentProps: PropTypes.object,
     43        className: PropTypes.string,
     44      }).isRequired,
     45    };
     46  }
     47 
     48  constructor(props) {
     49    super(props);
     50 
     51    this.contentRef = createRef();
     52 
     53    this._setTabbableState = this._setTabbableState.bind(this);
     54    this._onKeyDown = this._onKeyDown.bind(this);
     55  }
     56 
     57  componentDidMount() {
     58    this._setTabbableState();
     59  }
     60 
     61  componentDidUpdate() {
     62    this._setTabbableState();
     63  }
     64 
     65  _onKeyDown(event) {
     66    const { target, key, shiftKey } = event;
     67 
     68    if (key !== "Tab") {
     69      return;
     70    }
     71 
     72    const focusMoved = !!lazy.wrapMoveFocus(
     73      lazy.getFocusableElements(this.contentRef.current),
     74      target,
     75      shiftKey
     76    );
     77    if (focusMoved) {
     78      // Focus was moved to the begining/end of the list, so we need to prevent the
     79      // default focus change that would happen here.
     80      event.preventDefault();
     81    }
     82 
     83    event.stopPropagation();
     84  }
     85 
     86  /**
     87   * Makes sure that none of the focusable elements inside the list item container are
     88   * tabbable if the list item is not active. If the list item is active and focus is
     89   * outside its container, focus on the first focusable element inside.
     90   */
     91  _setTabbableState() {
     92    const elms = lazy.getFocusableElements(this.contentRef.current);
     93    if (elms.length === 0) {
     94      return;
     95    }
     96 
     97    if (!this.props.active) {
     98      elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
     99      return;
    100    }
    101 
    102    if (!elms.includes(document.activeElement)) {
    103      elms[0].focus();
    104    }
    105  }
    106 
    107  render() {
    108    const { active, item, current, onClick } = this.props;
    109    const { className, component, componentProps } = item;
    110 
    111    return li(
    112      {
    113        className: `${className}${current ? " current" : ""}${
    114          active ? " active" : ""
    115        }`,
    116        id: item.key,
    117        onClick,
    118        onKeyDownCapture: active ? this._onKeyDown : null,
    119      },
    120      div(
    121        {
    122          className: "list-item-content",
    123          role: "presentation",
    124          ref: this.contentRef,
    125        },
    126        cloneElement(component, componentProps || {})
    127      )
    128    );
    129  }
    130 }
    131 
    132 const ListItem = createFactory(ListItemClass);
    133 
    134 class List extends Component {
    135  static get propTypes() {
    136    return {
    137      // A list of all items to be rendered using a List component.
    138      items: PropTypes.arrayOf(
    139        PropTypes.shape({
    140          component: PropTypes.object,
    141          componentProps: PropTypes.object,
    142          className: PropTypes.string,
    143          key: PropTypes.string.isRequired,
    144        })
    145      ).isRequired,
    146 
    147      // Note: the two properties below are mutually exclusive. Only one of the
    148      // label properties is necessary.
    149      // ID of an element whose textual content serves as an accessible label for
    150      // a list.
    151      labelledBy: PropTypes.string,
    152 
    153      // Accessibility label for a list widget.
    154      label: PropTypes.string,
    155    };
    156  }
    157 
    158  constructor(props) {
    159    super(props);
    160 
    161    this.listRef = createRef();
    162 
    163    this.state = {
    164      active: null,
    165      current: null,
    166      mouseDown: false,
    167    };
    168 
    169    this._setCurrentItem = this._setCurrentItem.bind(this);
    170    this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this);
    171    this._onKeyDown = this._onKeyDown.bind(this);
    172  }
    173 
    174  shouldComponentUpdate(nextProps, nextState) {
    175    const { active, current, mouseDown } = this.state;
    176 
    177    return (
    178      current !== nextState.current ||
    179      active !== nextState.active ||
    180      mouseDown === nextState.mouseDown
    181    );
    182  }
    183 
    184  _preventArrowKeyScrolling(e) {
    185    switch (e.key) {
    186      case "ArrowUp":
    187      case "ArrowDown":
    188      case "ArrowLeft":
    189      case "ArrowRight":
    190        preventDefaultAndStopPropagation(e);
    191        break;
    192    }
    193  }
    194 
    195  /**
    196   * Sets the passed in item to be the current item.
    197   *
    198   * @param {null | number} index
    199   *        The index of the item in to be set as current, or undefined to unset the
    200   *        current item.
    201   */
    202  _setCurrentItem(index = -1, options = {}) {
    203    const item = this.props.items[index];
    204    if (item !== undefined && !options.preventAutoScroll) {
    205      const element = document.getElementById(item.key);
    206      scrollIntoView(element, {
    207        ...options,
    208        container: this.listRef.current,
    209      });
    210    }
    211 
    212    const state = {};
    213    if (this.state.active != undefined) {
    214      state.active = null;
    215      if (this.listRef.current !== document.activeElement) {
    216        this.listRef.current.focus();
    217      }
    218    }
    219 
    220    if (this.state.current !== index) {
    221      this.setState({
    222        ...state,
    223        current: index,
    224      });
    225    }
    226  }
    227 
    228  /**
    229   * Handles key down events in the list's container.
    230   *
    231   * @param {Event} e
    232   */
    233  _onKeyDown(e) {
    234    const { active, current } = this.state;
    235    if (current == null) {
    236      return;
    237    }
    238 
    239    if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
    240      return;
    241    }
    242 
    243    this._preventArrowKeyScrolling(e);
    244 
    245    const { length } = this.props.items;
    246    switch (e.key) {
    247      case "ArrowUp":
    248        current > 0 && this._setCurrentItem(current - 1, { alignTo: "top" });
    249        break;
    250 
    251      case "ArrowDown":
    252        current < length - 1 &&
    253          this._setCurrentItem(current + 1, { alignTo: "bottom" });
    254        break;
    255 
    256      case "Home":
    257        this._setCurrentItem(0, { alignTo: "top" });
    258        break;
    259 
    260      case "End":
    261        this._setCurrentItem(length - 1, { alignTo: "bottom" });
    262        break;
    263 
    264      case "Enter":
    265      case " ":
    266        // On space or enter make current list item active. This means keyboard focus
    267        // handling is passed on to the component within the list item.
    268        if (document.activeElement === this.listRef.current) {
    269          preventDefaultAndStopPropagation(e);
    270          if (active !== current) {
    271            this.setState({ active: current });
    272          }
    273        }
    274        break;
    275 
    276      case "Escape":
    277        // If current list item is active, make it inactive and let keyboard focusing be
    278        // handled normally.
    279        preventDefaultAndStopPropagation(e);
    280        if (active != null) {
    281          this.setState({ active: null });
    282        }
    283 
    284        this.listRef.current.focus();
    285        break;
    286    }
    287  }
    288 
    289  render() {
    290    const { active, current } = this.state;
    291    const { items } = this.props;
    292 
    293    return ul(
    294      {
    295        ref: this.listRef,
    296        className: "list",
    297        tabIndex: 0,
    298        onKeyDown: this._onKeyDown,
    299        onKeyPress: this._preventArrowKeyScrolling,
    300        onKeyUp: this._preventArrowKeyScrolling,
    301        onMouseDown: () => this.setState({ mouseDown: true }),
    302        onMouseUp: () => this.setState({ mouseDown: false }),
    303        onFocus: () => {
    304          if (current != null || this.state.mouseDown) {
    305            return;
    306          }
    307 
    308          // Only set default current to the first list item if current item is
    309          // not yet set and the focus event is not the result of a mouse
    310          // interarction.
    311          this._setCurrentItem(0);
    312        },
    313        onClick: () => {
    314          // Focus should always remain on the list container itself.
    315          this.listRef.current.focus();
    316        },
    317        onBlur: e => {
    318          if (active != null) {
    319            const { relatedTarget } = e;
    320            if (!this.listRef.current.contains(relatedTarget)) {
    321              this.setState({ active: null });
    322            }
    323          }
    324        },
    325        "aria-label": this.props.label,
    326        "aria-labelledby": this.props.labelledBy,
    327        "aria-activedescendant": current != null ? items[current].key : null,
    328      },
    329      items.map((item, index) => {
    330        return ListItem({
    331          item,
    332          current: index === current,
    333          active: index === active,
    334          // We make a key unique depending on whether the list item is in active or
    335          // inactive state to make sure that it is actually replaced and the tabbable
    336          // state is reset.
    337          key: `${item.key}-${index === active ? "active" : "inactive"}`,
    338          // Since the user just clicked the item, there's no need to check if it should
    339          // be scrolled into view.
    340          onClick: () =>
    341            this._setCurrentItem(index, { preventAutoScroll: true }),
    342        });
    343      })
    344    );
    345  }
    346 }
    347 
    348 module.exports = {
    349  ListItem: ListItemClass,
    350  List,
    351 };