/* 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,
};