351 lines
9.3 KiB
JavaScript
351 lines
9.3 KiB
JavaScript
/* 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.mjs");
|
|
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
|
|
const {
|
|
ul,
|
|
li,
|
|
div,
|
|
} = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
|
|
|
|
const { scrollIntoView } = ChromeUtils.importESModule(
|
|
"resource://devtools/client/shared/scroll.mjs"
|
|
);
|
|
const {
|
|
preventDefaultAndStopPropagation,
|
|
} = require("resource://devtools/client/shared/events.js");
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
wrapMoveFocus: "resource://devtools/client/shared/focus.mjs",
|
|
getFocusableElements: "resource://devtools/client/shared/focus.mjs",
|
|
});
|
|
|
|
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 = !!lazy.wrapMoveFocus(
|
|
lazy.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 = lazy.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,
|
|
};
|