MenuList.js (4530B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 // A list of menu items. 8 // 9 // This component provides keyboard navigation amongst any focusable 10 // children. 11 12 const { 13 Children, 14 PureComponent, 15 } = require("resource://devtools/client/shared/vendor/react.mjs"); 16 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 17 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 18 const { div } = dom; 19 20 const lazy = {}; 21 ChromeUtils.defineESModuleGetters(lazy, { 22 focusableSelector: "resource://devtools/client/shared/focus.mjs", 23 }); 24 25 class MenuList extends PureComponent { 26 static get propTypes() { 27 return { 28 // ID to assign to the list container. 29 id: PropTypes.string, 30 31 // Children of the list. 32 children: PropTypes.any, 33 34 // Called whenever there is a change to the hovered or selected child. 35 // The callback is passed the ID of the highlighted child or null if no 36 // child is highlighted. 37 onHighlightedChildChange: PropTypes.func, 38 }; 39 } 40 41 constructor(props) { 42 super(props); 43 44 this.onKeyDown = this.onKeyDown.bind(this); 45 this.onMouseOverOrFocus = this.onMouseOverOrFocus.bind(this); 46 this.onMouseOutOrBlur = this.onMouseOutOrBlur.bind(this); 47 this.notifyHighlightedChildChange = 48 this.notifyHighlightedChildChange.bind(this); 49 50 this.setWrapperRef = element => { 51 this.wrapperRef = element; 52 }; 53 } 54 55 onMouseOverOrFocus(e) { 56 this.notifyHighlightedChildChange(e.target.id); 57 } 58 59 onMouseOutOrBlur() { 60 const hoveredElem = this.wrapperRef.querySelector(":hover"); 61 if (!hoveredElem) { 62 this.notifyHighlightedChildChange(null); 63 } 64 } 65 66 notifyHighlightedChildChange(id) { 67 if (this.props.onHighlightedChildChange) { 68 this.props.onHighlightedChildChange(id); 69 } 70 } 71 72 onKeyDown(e) { 73 // Check if the focus is in the list. 74 if ( 75 !this.wrapperRef || 76 !this.wrapperRef.contains(e.target.ownerDocument.activeElement) 77 ) { 78 return; 79 } 80 81 const getTabList = () => 82 Array.from(this.wrapperRef.querySelectorAll(lazy.focusableSelector)); 83 84 switch (e.key) { 85 case "Tab": 86 case "ArrowUp": 87 case "ArrowDown": 88 { 89 const tabList = getTabList(); 90 const currentElement = e.target.ownerDocument.activeElement; 91 const currentIndex = tabList.indexOf(currentElement); 92 if (currentIndex !== -1) { 93 let nextIndex; 94 if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { 95 nextIndex = 96 currentIndex === tabList.length - 1 ? 0 : currentIndex + 1; 97 } else { 98 nextIndex = 99 currentIndex === 0 ? tabList.length - 1 : currentIndex - 1; 100 } 101 tabList[nextIndex].focus(); 102 e.preventDefault(); 103 } 104 } 105 break; 106 107 case "Home": 108 { 109 const firstItem = this.wrapperRef.querySelector( 110 lazy.focusableSelector 111 ); 112 if (firstItem) { 113 firstItem.focus(); 114 e.preventDefault(); 115 } 116 } 117 break; 118 119 case "End": 120 { 121 const tabList = getTabList(); 122 if (tabList.length) { 123 tabList[tabList.length - 1].focus(); 124 e.preventDefault(); 125 } 126 } 127 break; 128 } 129 } 130 131 render() { 132 const attr = { 133 role: "menu", 134 ref: this.setWrapperRef, 135 onKeyDown: this.onKeyDown, 136 onMouseOver: this.onMouseOverOrFocus, 137 onMouseOut: this.onMouseOutOrBlur, 138 onFocus: this.onMouseOverOrFocus, 139 onBlur: this.onMouseOutOrBlur, 140 className: "menu-standard-padding", 141 }; 142 143 if (this.props.id) { 144 attr.id = this.props.id; 145 } 146 147 // Add padding for checkbox image if necessary. 148 let hasCheckbox = false; 149 Children.forEach(this.props.children, (child, i) => { 150 if (child == null || typeof child == "undefined") { 151 console.warn("MenuList children at index", i, "is", child); 152 return; 153 } 154 155 if (typeof child?.props?.checked !== "undefined") { 156 hasCheckbox = true; 157 } 158 }); 159 if (hasCheckbox) { 160 attr.className = "checkbox-container menu-standard-padding"; 161 } 162 163 return div(attr, this.props.children); 164 } 165 } 166 167 module.exports = MenuList;