177 lines
4.9 KiB
JavaScript
177 lines
4.9 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/. */
|
|
|
|
import React from "react";
|
|
import { connect } from "react-redux";
|
|
|
|
export class ContextMenu extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.hideContext = this.hideContext.bind(this);
|
|
this.onShow = this.onShow.bind(this);
|
|
this.onClick = this.onClick.bind(this);
|
|
}
|
|
|
|
hideContext() {
|
|
this.props.onUpdate(false);
|
|
}
|
|
|
|
onShow() {
|
|
if (this.props.onShow) {
|
|
this.props.onShow();
|
|
}
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.onShow();
|
|
setTimeout(() => {
|
|
globalThis.addEventListener("click", this.hideContext);
|
|
}, 0);
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
globalThis.removeEventListener("click", this.hideContext);
|
|
}
|
|
|
|
onClick(event) {
|
|
// Eat all clicks on the context menu so they don't bubble up to window.
|
|
// This prevents the context menu from closing when clicking disabled items
|
|
// or the separators.
|
|
event.stopPropagation();
|
|
}
|
|
|
|
render() {
|
|
// Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper.
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/interactive-supports-focus
|
|
<span className="context-menu">
|
|
<ul
|
|
role="menu"
|
|
onClick={this.onClick}
|
|
onKeyDown={this.onClick}
|
|
className="context-menu-list"
|
|
>
|
|
{this.props.options.map((option, i) =>
|
|
option.type === "separator" ? (
|
|
<li key={i} className="separator" role="separator" />
|
|
) : (
|
|
option.type !== "empty" && (
|
|
<ContextMenuItem
|
|
key={i}
|
|
option={option}
|
|
hideContext={this.hideContext}
|
|
keyboardAccess={this.props.keyboardAccess}
|
|
/>
|
|
)
|
|
)
|
|
)}
|
|
</ul>
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
export class _ContextMenuItem extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.onClick = this.onClick.bind(this);
|
|
this.onKeyDown = this.onKeyDown.bind(this);
|
|
this.onKeyUp = this.onKeyUp.bind(this);
|
|
this.focusFirst = this.focusFirst.bind(this);
|
|
}
|
|
|
|
onClick(event) {
|
|
this.props.hideContext();
|
|
this.props.option.onClick(event);
|
|
}
|
|
|
|
// Focus the first menu item if the menu was accessed via the keyboard.
|
|
focusFirst(button) {
|
|
if (this.props.keyboardAccess && button) {
|
|
button.focus();
|
|
}
|
|
}
|
|
|
|
// This selects the correct node based on the key pressed
|
|
focusSibling(target, key) {
|
|
const { parentNode } = target;
|
|
const closestSiblingSelector =
|
|
key === "ArrowUp" ? "previousSibling" : "nextSibling";
|
|
if (!parentNode[closestSiblingSelector]) {
|
|
return;
|
|
}
|
|
if (parentNode[closestSiblingSelector].firstElementChild) {
|
|
parentNode[closestSiblingSelector].firstElementChild.focus();
|
|
} else {
|
|
parentNode[closestSiblingSelector][
|
|
closestSiblingSelector
|
|
].firstElementChild.focus();
|
|
}
|
|
}
|
|
|
|
onKeyDown(event) {
|
|
const { option } = this.props;
|
|
switch (event.key) {
|
|
case "Tab":
|
|
// tab goes down in context menu, shift + tab goes up in context menu
|
|
// if we're on the last item, one more tab will close the context menu
|
|
// similarly, if we're on the first item, one more shift + tab will close it
|
|
if (
|
|
(event.shiftKey && option.first) ||
|
|
(!event.shiftKey && option.last)
|
|
) {
|
|
this.props.hideContext();
|
|
}
|
|
break;
|
|
case "ArrowUp":
|
|
case "ArrowDown":
|
|
event.preventDefault();
|
|
this.focusSibling(event.target, event.key);
|
|
break;
|
|
case "Enter":
|
|
case " ":
|
|
event.preventDefault();
|
|
this.props.hideContext();
|
|
option.onClick();
|
|
break;
|
|
case "Escape":
|
|
this.props.hideContext();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Prevents the default behavior of spacebar
|
|
// scrolling the page & auto-triggering buttons.
|
|
onKeyUp(event) {
|
|
if (event.key === " ") {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
render() {
|
|
const { option } = this.props;
|
|
const className = [option.disabled ? "disabled" : ""].join(" ");
|
|
return (
|
|
<li role="presentation" className="context-menu-item">
|
|
<button
|
|
role="menuitem"
|
|
className={className}
|
|
onClick={this.onClick}
|
|
onKeyDown={this.onKeyDown}
|
|
onKeyUp={this.onKeyUp}
|
|
ref={option.first ? this.focusFirst : null}
|
|
aria-haspopup={
|
|
option.id === "newtab-menu-edit-topsites" ? "dialog" : null
|
|
}
|
|
>
|
|
<span data-l10n-id={option.string_id || option.id} />
|
|
</button>
|
|
</li>
|
|
);
|
|
}
|
|
}
|
|
|
|
export const ContextMenuItem = connect(state => ({
|
|
Prefs: state.Prefs,
|
|
}))(_ContextMenuItem);
|