/* 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/. */
/* eslint-env browser */
"use strict";
// A list of menu items.
//
// This component provides keyboard navigation amongst any focusable
// children.
const {
Children,
PureComponent,
} = require(
"resource://devtools/client/shared/vendor/react.js");
const PropTypes = require(
"resource://devtools/client/shared/vendor/react-prop-types.js");
const dom = require(
"resource://devtools/client/shared/vendor/react-dom-factories.js");
const { div } = dom;
const {
focusableSelector,
} = require(
"resource://devtools/client/shared/focus.js");
class MenuList
extends PureComponent {
static get propTypes() {
return {
// ID to assign to the list container.
id: PropTypes.string,
// Children of the list.
children: PropTypes.any,
// Called whenever there is a change to the hovered or selected child.
// The callback is passed the ID of the highlighted child or null if no
// child is highlighted.
onHighlightedChildChange: PropTypes.func,
};
}
constructor(props) {
super(props);
this.onKeyDown =
this.onKeyDown.bind(
this);
this.onMouseOverOrFocus =
this.onMouseOverOrFocus.bind(
this);
this.onMouseOutOrBlur =
this.onMouseOutOrBlur.bind(
this);
this.notifyHighlightedChildChange =
this.notifyHighlightedChildChange.bind(
this);
this.setWrapperRef = element => {
this.wrapperRef = element;
};
}
onMouseOverOrFocus(e) {
this.notifyHighlightedChildChange(e.target.id);
}
onMouseOutOrBlur() {
const hoveredElem =
this.wrapperRef.querySelector(
":hover");
if (!hoveredElem) {
this.notifyHighlightedChildChange(
null);
}
}
notifyHighlightedChildChange(id) {
if (
this.props.onHighlightedChildChange) {
this.props.onHighlightedChildChange(id);
}
}
onKeyDown(e) {
// Check if the focus is in the list.
if (
!
this.wrapperRef ||
!
this.wrapperRef.contains(e.target.ownerDocument.activeElement)
) {
return;
}
const getTabList = () =>
Array.from(
this.wrapperRef.querySelectorAll(focusableSelector));
switch (e.key) {
case "Tab":
case "ArrowUp":
case "ArrowDown":
{
const tabList = getTabList();
const currentElement = e.target.ownerDocument.activeElement;
const currentIndex = tabList.indexOf(currentElement);
if (currentIndex !== -1) {
let nextIndex;
if (e.key ===
"ArrowDown" || (e.key ===
"Tab" && !e.shiftKey)) {
nextIndex =
currentIndex === tabList.length - 1 ? 0 : currentIndex + 1;
}
else {
nextIndex =
currentIndex === 0 ? tabList.length - 1 : currentIndex - 1;
}
tabList[nextIndex].focus();
e.preventDefault();
}
}
break;
case "Home":
{
const firstItem =
this.wrapperRef.querySelector(focusableSelector);
if (firstItem) {
firstItem.focus();
e.preventDefault();
}
}
break;
case "End":
{
const tabList = getTabList();
if (tabList.length) {
tabList[tabList.length - 1].focus();
e.preventDefault();
}
}
break;
}
}
render() {
const attr = {
role:
"menu",
ref:
this.setWrapperRef,
onKeyDown:
this.onKeyDown,
onMouseOver:
this.onMouseOverOrFocus,
onMouseOut:
this.onMouseOutOrBlur,
onFocus:
this.onMouseOverOrFocus,
onBlur:
this.onMouseOutOrBlur,
className:
"menu-standard-padding",
};
if (
this.props.id) {
attr.id =
this.props.id;
}
// Add padding for checkbox image if necessary.
let hasCheckbox =
false;
Children.forEach(
this.props.children, (child, i) => {
if (child ==
null ||
typeof child ==
"undefined") {
console.warn(
"MenuList children at index", i,
"is", child);
return;
}
if (
typeof child?.props?.checked !==
"undefined") {
hasCheckbox =
true;
}
});
if (hasCheckbox) {
attr.className =
"checkbox-container menu-standard-padding";
}
return div(attr,
this.props.children);
}
}
module.exports = MenuList;