ContextMenu.jsx (4985B)
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 import React from "react"; 6 import { connect } from "react-redux"; 7 8 export class ContextMenu extends React.PureComponent { 9 constructor(props) { 10 super(props); 11 this.hideContext = this.hideContext.bind(this); 12 this.onShow = this.onShow.bind(this); 13 this.onClick = this.onClick.bind(this); 14 } 15 16 hideContext() { 17 this.props.onUpdate(false); 18 } 19 20 onShow() { 21 if (this.props.onShow) { 22 this.props.onShow(); 23 } 24 } 25 26 componentDidMount() { 27 this.onShow(); 28 setTimeout(() => { 29 globalThis.addEventListener("click", this.hideContext); 30 }, 0); 31 } 32 33 componentWillUnmount() { 34 globalThis.removeEventListener("click", this.hideContext); 35 } 36 37 onClick(event) { 38 // Eat all clicks on the context menu so they don't bubble up to window. 39 // This prevents the context menu from closing when clicking disabled items 40 // or the separators. 41 event.stopPropagation(); 42 } 43 44 render() { 45 // Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper. 46 return ( 47 // eslint-disable-next-line jsx-a11y/interactive-supports-focus 48 <span className="context-menu"> 49 <ul 50 role="menu" 51 onClick={this.onClick} 52 onKeyDown={this.onClick} 53 className="context-menu-list" 54 > 55 {this.props.options.map((option, i) => 56 option.type === "separator" ? ( 57 <li key={i} className="separator" role="separator" /> 58 ) : ( 59 option.type !== "empty" && ( 60 <ContextMenuItem 61 key={i} 62 option={option} 63 hideContext={this.hideContext} 64 keyboardAccess={this.props.keyboardAccess} 65 /> 66 ) 67 ) 68 )} 69 </ul> 70 </span> 71 ); 72 } 73 } 74 75 export class _ContextMenuItem extends React.PureComponent { 76 constructor(props) { 77 super(props); 78 this.onClick = this.onClick.bind(this); 79 this.onKeyDown = this.onKeyDown.bind(this); 80 this.onKeyUp = this.onKeyUp.bind(this); 81 this.focusFirst = this.focusFirst.bind(this); 82 } 83 84 onClick(event) { 85 this.props.hideContext(); 86 this.props.option.onClick(event); 87 } 88 89 // Focus the first menu item if the menu was accessed via the keyboard. 90 focusFirst(button) { 91 if (this.props.keyboardAccess && button) { 92 button.focus(); 93 } 94 } 95 96 // This selects the correct node based on the key pressed 97 focusSibling(target, key) { 98 const { parentNode } = target; 99 const closestSiblingSelector = 100 key === "ArrowUp" ? "previousSibling" : "nextSibling"; 101 if (!parentNode[closestSiblingSelector]) { 102 return; 103 } 104 if (parentNode[closestSiblingSelector].firstElementChild) { 105 parentNode[closestSiblingSelector].firstElementChild.focus(); 106 } else { 107 parentNode[closestSiblingSelector][ 108 closestSiblingSelector 109 ].firstElementChild.focus(); 110 } 111 } 112 113 onKeyDown(event) { 114 const { option } = this.props; 115 switch (event.key) { 116 case "Tab": 117 // tab goes down in context menu, shift + tab goes up in context menu 118 // if we're on the last item, one more tab will close the context menu 119 // similarly, if we're on the first item, one more shift + tab will close it 120 if ( 121 (event.shiftKey && option.first) || 122 (!event.shiftKey && option.last) 123 ) { 124 this.props.hideContext(); 125 } 126 break; 127 case "ArrowUp": 128 case "ArrowDown": 129 event.preventDefault(); 130 this.focusSibling(event.target, event.key); 131 break; 132 case "Enter": 133 case " ": 134 event.preventDefault(); 135 this.props.hideContext(); 136 option.onClick(); 137 break; 138 case "Escape": 139 this.props.hideContext(); 140 break; 141 } 142 } 143 144 // Prevents the default behavior of spacebar 145 // scrolling the page & auto-triggering buttons. 146 onKeyUp(event) { 147 if (event.key === " ") { 148 event.preventDefault(); 149 } 150 } 151 152 render() { 153 const { option } = this.props; 154 const className = [option.disabled ? "disabled" : ""].join(" "); 155 return ( 156 <li role="presentation" className="context-menu-item"> 157 <button 158 role="menuitem" 159 className={className} 160 onClick={this.onClick} 161 onKeyDown={this.onKeyDown} 162 onKeyUp={this.onKeyUp} 163 ref={option.first ? this.focusFirst : null} 164 aria-haspopup={ 165 option.id === "newtab-menu-edit-topsites" ? "dialog" : null 166 } 167 > 168 <span data-l10n-id={option.string_id || option.id} /> 169 </button> 170 </li> 171 ); 172 } 173 } 174 175 export const ContextMenuItem = connect(state => ({ 176 Prefs: state.Prefs, 177 }))(_ContextMenuItem);