summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/components/List.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/components/List.js')
-rw-r--r--devtools/client/shared/components/List.js352
1 files changed, 352 insertions, 0 deletions
diff --git a/devtools/client/shared/components/List.js b/devtools/client/shared/components/List.js
new file mode 100644
index 0000000000..95c3ffe4dd
--- /dev/null
+++ b/devtools/client/shared/components/List.js
@@ -0,0 +1,352 @@
+/* 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 {
+ createFactory,
+ createRef,
+ Component,
+ cloneElement,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ ul,
+ li,
+ div,
+} = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+
+const {
+ scrollIntoView,
+} = require("resource://devtools/client/shared/scroll.js");
+const {
+ preventDefaultAndStopPropagation,
+} = require("resource://devtools/client/shared/events.js");
+
+loader.lazyRequireGetter(
+ this,
+ ["getFocusableElements", "wrapMoveFocus"],
+ "resource://devtools/client/shared/focus.js",
+ true
+);
+
+class ListItemClass extends Component {
+ static get propTypes() {
+ return {
+ active: PropTypes.bool,
+ current: PropTypes.bool,
+ onClick: PropTypes.func,
+ item: PropTypes.shape({
+ key: PropTypes.string,
+ component: PropTypes.object,
+ componentProps: PropTypes.object,
+ className: PropTypes.string,
+ }).isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.contentRef = createRef();
+
+ this._setTabbableState = this._setTabbableState.bind(this);
+ this._onKeyDown = this._onKeyDown.bind(this);
+ }
+
+ componentDidMount() {
+ this._setTabbableState();
+ }
+
+ componentDidUpdate() {
+ this._setTabbableState();
+ }
+
+ _onKeyDown(event) {
+ const { target, key, shiftKey } = event;
+
+ if (key !== "Tab") {
+ return;
+ }
+
+ const focusMoved = !!wrapMoveFocus(
+ getFocusableElements(this.contentRef.current),
+ target,
+ shiftKey
+ );
+ if (focusMoved) {
+ // Focus was moved to the begining/end of the list, so we need to prevent the
+ // default focus change that would happen here.
+ event.preventDefault();
+ }
+
+ event.stopPropagation();
+ }
+
+ /**
+ * Makes sure that none of the focusable elements inside the list item container are
+ * tabbable if the list item is not active. If the list item is active and focus is
+ * outside its container, focus on the first focusable element inside.
+ */
+ _setTabbableState() {
+ const elms = getFocusableElements(this.contentRef.current);
+ if (elms.length === 0) {
+ return;
+ }
+
+ if (!this.props.active) {
+ elms.forEach(elm => elm.setAttribute("tabindex", "-1"));
+ return;
+ }
+
+ if (!elms.includes(document.activeElement)) {
+ elms[0].focus();
+ }
+ }
+
+ render() {
+ const { active, item, current, onClick } = this.props;
+ const { className, component, componentProps } = item;
+
+ return li(
+ {
+ className: `${className}${current ? " current" : ""}${
+ active ? " active" : ""
+ }`,
+ id: item.key,
+ onClick,
+ onKeyDownCapture: active ? this._onKeyDown : null,
+ },
+ div(
+ {
+ className: "list-item-content",
+ role: "presentation",
+ ref: this.contentRef,
+ },
+ cloneElement(component, componentProps || {})
+ )
+ );
+ }
+}
+
+const ListItem = createFactory(ListItemClass);
+
+class List extends Component {
+ static get propTypes() {
+ return {
+ // A list of all items to be rendered using a List component.
+ items: PropTypes.arrayOf(
+ PropTypes.shape({
+ component: PropTypes.object,
+ componentProps: PropTypes.object,
+ className: PropTypes.string,
+ key: PropTypes.string.isRequired,
+ })
+ ).isRequired,
+
+ // Note: the two properties below are mutually exclusive. Only one of the
+ // label properties is necessary.
+ // ID of an element whose textual content serves as an accessible label for
+ // a list.
+ labelledBy: PropTypes.string,
+
+ // Accessibility label for a list widget.
+ label: PropTypes.string,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.listRef = createRef();
+
+ this.state = {
+ active: null,
+ current: null,
+ mouseDown: false,
+ };
+
+ this._setCurrentItem = this._setCurrentItem.bind(this);
+ this._preventArrowKeyScrolling = this._preventArrowKeyScrolling.bind(this);
+ this._onKeyDown = this._onKeyDown.bind(this);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const { active, current, mouseDown } = this.state;
+
+ return (
+ current !== nextState.current ||
+ active !== nextState.active ||
+ mouseDown === nextState.mouseDown
+ );
+ }
+
+ _preventArrowKeyScrolling(e) {
+ switch (e.key) {
+ case "ArrowUp":
+ case "ArrowDown":
+ case "ArrowLeft":
+ case "ArrowRight":
+ preventDefaultAndStopPropagation(e);
+ break;
+ }
+ }
+
+ /**
+ * Sets the passed in item to be the current item.
+ *
+ * @param {null|Number} index
+ * The index of the item in to be set as current, or undefined to unset the
+ * current item.
+ */
+ _setCurrentItem(index = -1, options = {}) {
+ const item = this.props.items[index];
+ if (item !== undefined && !options.preventAutoScroll) {
+ const element = document.getElementById(item.key);
+ scrollIntoView(element, {
+ ...options,
+ container: this.listRef.current,
+ });
+ }
+
+ const state = {};
+ if (this.state.active != undefined) {
+ state.active = null;
+ if (this.listRef.current !== document.activeElement) {
+ this.listRef.current.focus();
+ }
+ }
+
+ if (this.state.current !== index) {
+ this.setState({
+ ...state,
+ current: index,
+ });
+ }
+ }
+
+ /**
+ * Handles key down events in the list's container.
+ *
+ * @param {Event} e
+ */
+ _onKeyDown(e) {
+ const { active, current } = this.state;
+ if (current == null) {
+ return;
+ }
+
+ if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
+ return;
+ }
+
+ this._preventArrowKeyScrolling(e);
+
+ const { length } = this.props.items;
+ switch (e.key) {
+ case "ArrowUp":
+ current > 0 && this._setCurrentItem(current - 1, { alignTo: "top" });
+ break;
+
+ case "ArrowDown":
+ current < length - 1 &&
+ this._setCurrentItem(current + 1, { alignTo: "bottom" });
+ break;
+
+ case "Home":
+ this._setCurrentItem(0, { alignTo: "top" });
+ break;
+
+ case "End":
+ this._setCurrentItem(length - 1, { alignTo: "bottom" });
+ break;
+
+ case "Enter":
+ case " ":
+ // On space or enter make current list item active. This means keyboard focus
+ // handling is passed on to the component within the list item.
+ if (document.activeElement === this.listRef.current) {
+ preventDefaultAndStopPropagation(e);
+ if (active !== current) {
+ this.setState({ active: current });
+ }
+ }
+ break;
+
+ case "Escape":
+ // If current list item is active, make it inactive and let keyboard focusing be
+ // handled normally.
+ preventDefaultAndStopPropagation(e);
+ if (active != null) {
+ this.setState({ active: null });
+ }
+
+ this.listRef.current.focus();
+ break;
+ }
+ }
+
+ render() {
+ const { active, current } = this.state;
+ const { items } = this.props;
+
+ return ul(
+ {
+ ref: this.listRef,
+ className: "list",
+ tabIndex: 0,
+ onKeyDown: this._onKeyDown,
+ onKeyPress: this._preventArrowKeyScrolling,
+ onKeyUp: this._preventArrowKeyScrolling,
+ onMouseDown: () => this.setState({ mouseDown: true }),
+ onMouseUp: () => this.setState({ mouseDown: false }),
+ onFocus: () => {
+ if (current != null || this.state.mouseDown) {
+ return;
+ }
+
+ // Only set default current to the first list item if current item is
+ // not yet set and the focus event is not the result of a mouse
+ // interarction.
+ this._setCurrentItem(0);
+ },
+ onClick: () => {
+ // Focus should always remain on the list container itself.
+ this.listRef.current.focus();
+ },
+ onBlur: e => {
+ if (active != null) {
+ const { relatedTarget } = e;
+ if (!this.listRef.current.contains(relatedTarget)) {
+ this.setState({ active: null });
+ }
+ }
+ },
+ "aria-label": this.props.label,
+ "aria-labelledby": this.props.labelledBy,
+ "aria-activedescendant": current != null ? items[current].key : null,
+ },
+ items.map((item, index) => {
+ return ListItem({
+ item,
+ current: index === current,
+ active: index === active,
+ // We make a key unique depending on whether the list item is in active or
+ // inactive state to make sure that it is actually replaced and the tabbable
+ // state is reset.
+ key: `${item.key}-${index === active ? "active" : "inactive"}`,
+ // Since the user just clicked the item, there's no need to check if it should
+ // be scrolled into view.
+ onClick: () =>
+ this._setCurrentItem(index, { preventAutoScroll: true }),
+ });
+ })
+ );
+ }
+}
+
+module.exports = {
+ ListItem: ListItemClass,
+ List,
+};