Bug 1461522 - Add Menu components; r=jdescottes

MozReview-Commit-ID: DJVU4rRYQYU

--HG--
extra : rebase_source : e855f004085749562ba5fce75abf748b284b100b
This commit is contained in:
Brian Birtles 2018-06-28 15:13:06 +09:00
Родитель 59e52349bd
Коммит 9518077b79
5 изменённых файлов: 464 добавлений и 0 удалений

Просмотреть файл

@ -0,0 +1,259 @@
/* 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 button that toggles a doorhanger menu.
const { PureComponent } = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const ReactDOM = require("devtools/client/shared/vendor/react-dom");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const { button } = dom;
const {
HTMLTooltip,
} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
class MenuButton extends PureComponent {
static get propTypes() {
return {
// The document to be used for rendering the menu popup.
doc: PropTypes.object.isRequired,
// An optional ID to assign to the menu's container tooltip object.
menuId: PropTypes.string,
// The preferred side of the anchor element to display the menu.
// Defaults to "bottom".
menuPosition: PropTypes.string.isRequired,
// The offset of the menu from the anchor element.
// Defaults to -5.
menuOffset: PropTypes.number.isRequired,
// The menu content.
children: PropTypes.any,
// Callback function to be invoked when the button is clicked.
onClick: PropTypes.func,
};
}
static get defaultProps() {
return {
menuPosition: "bottom",
menuOffset: -5,
};
}
constructor(props) {
super(props);
this.showMenu = this.showMenu.bind(this);
this.hideMenu = this.hideMenu.bind(this);
this.toggleMenu = this.toggleMenu.bind(this);
this.onHidden = this.onHidden.bind(this);
this.onClick = this.onClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.tooltip = null;
this.buttonRef = null;
this.setButtonRef = element => {
this.buttonRef = element;
};
this.state = {
expanded: false,
win: props.doc.defaultView.top,
};
}
componentWillMount() {
this.initializeTooltip();
}
componentWillReceiveProps(nextProps) {
// If the window changes, we need to regenerate the HTMLTooltip or else the
// XUL wrapper element will appear above (in terms of z-index) the old
// window, and not the new.
const win = nextProps.doc.defaultView.top;
if (
nextProps.doc !== this.props.doc ||
this.state.win !== win ||
nextProps.menuId !== this.props.menuId
) {
this.setState({ win });
this.resetTooltip();
this.initializeTooltip();
}
}
componentWillUnmount() {
this.resetTooltip();
}
initializeTooltip() {
const tooltipProps = {
type: "doorhanger",
useXulWrapper: true,
};
if (this.props.menuId) {
tooltipProps.id = this.props.menuId;
}
this.tooltip = new HTMLTooltip(this.props.doc, tooltipProps);
this.tooltip.on("hidden", this.onHidden);
}
async resetTooltip() {
if (!this.tooltip) {
return;
}
// Mark the menu as closed since the onHidden callback may not be called in
// this case.
this.setState({ expanded: false });
this.tooltip.destroy();
this.tooltip.off("hidden", this.onHidden);
this.tooltip = null;
}
async showMenu(anchor) {
this.setState({
expanded: true
});
if (!this.tooltip) {
return;
}
await this.tooltip.show(anchor, {
position: this.props.menuPosition,
y: this.props.menuOffset,
});
}
async hideMenu() {
this.setState({
expanded: false
});
if (!this.tooltip) {
return;
}
await this.tooltip.hide();
}
async toggleMenu(anchor) {
return this.state.expanded ? this.hideMenu() : this.showMenu(anchor);
}
// Used by the call site to indicate that the menu content has changed so
// its container should be updated.
resizeContent() {
if (!this.state.expanded || !this.tooltip || !this.buttonRef) {
return;
}
this.tooltip.updateContainerBounds(this.buttonRef, {
position: this.props.menuPosition,
y: this.props.menuOffset,
});
}
onHidden() {
this.setState({ expanded: false });
}
async onClick(e) {
if (e.target === this.buttonRef) {
if (this.props.onClick) {
this.props.onClick(e);
}
if (!e.defaultPrevented) {
const wasKeyboardEvent = e.screenX === 0 && e.screenY === 0;
await this.toggleMenu(e.target);
// If the menu was activated by keyboard, focus the first item.
if (wasKeyboardEvent && this.tooltip) {
this.tooltip.focus();
}
}
// If we clicked one of the menu items, then, by default, we should
// auto-collapse the menu.
//
// We check for the defaultPrevented state, however, so that menu items can
// turn this behavior off (e.g. a menu item with an embedded button).
} else if (this.state.expanded && !e.defaultPrevented) {
this.hideMenu();
}
}
onKeyDown(e) {
if (!this.state.expanded) {
return;
}
const isButtonFocussed =
this.props.doc && this.props.doc.activeElement === this.buttonRef;
switch (e.key) {
case "Escape":
this.hideMenu();
e.preventDefault();
break;
case "Tab":
case "ArrowDown":
if (isButtonFocussed && this.tooltip) {
if (this.tooltip.focus()) {
e.preventDefault();
}
}
break;
case "ArrowUp":
if (isButtonFocussed && this.tooltip) {
if (this.tooltip.focusEnd()) {
e.preventDefault();
}
}
break;
}
}
render() {
// We bypass the call to HTMLTooltip. setContent and set the panel contents
// directly here.
//
// Bug 1472942: Do this for all users of HTMLTooltip.
const menu = ReactDOM.createPortal(
this.props.children,
this.tooltip.panel
);
const buttonProps = {
...this.props,
onClick: this.onClick,
"aria-expanded": this.state.expanded,
"aria-haspopup": "menu",
ref: this.setButtonRef,
};
if (this.state.expanded) {
buttonProps.onKeyDown = this.onKeyDown;
}
if (this.props.menuId) {
buttonProps["aria-controls"] = this.props.menuId;
}
return button(buttonProps, menu);
}
}
module.exports = MenuButton;

Просмотреть файл

@ -0,0 +1,77 @@
/* 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 command in a menu.
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const { button, li, span } = dom;
const MenuItem = props => {
const attr = {
className: "command"
};
if (props.id) {
attr.id = props.id;
}
if (props.className) {
attr.className += " " + props.className;
}
if (props.onClick) {
attr.onClick = props.onClick;
}
if (typeof props.checked !== "undefined") {
attr.role = "menuitemcheckbox";
if (props.checked) {
attr["aria-checked"] = true;
}
} else {
attr.role = "menuitem";
}
const textLabel = span({ className: "label" }, props.label);
const children = [textLabel];
if (typeof props.accelerator !== "undefined") {
const acceleratorLabel = span(
{ className: "accelerator" },
props.accelerator
);
children.push(acceleratorLabel);
}
return li({ className: "menuitem" }, button(attr, children));
};
MenuItem.propTypes = {
// An optional keyboard shortcut to display next to the item.
// (This does not actually register the event listener for the key.)
accelerator: PropTypes.string,
// A tri-state value that may be true/false if item should be checkable, and
// undefined otherwise.
checked: PropTypes.bool,
// Any additional classes to assign to the button specified as
// a space-separated string.
className: PropTypes.string,
// An optional ID to be assigned to the item.
id: PropTypes.string,
// The item label.
label: PropTypes.string.isRequired,
// An optional callback to be invoked when the item is selected.
onClick: PropTypes.func,
};
module.exports = MenuItem;

Просмотреть файл

@ -0,0 +1,116 @@
/* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const { div } = dom;
const { focusableSelector } = require("devtools/client/shared/focus");
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,
};
}
constructor(props) {
super(props);
this.onKeyDown = this.onKeyDown.bind(this);
this.setWrapperRef = element => {
this.wrapperRef = element;
};
}
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 "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") {
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,
};
if (this.props.id) {
attr.id = this.props.id;
}
return div(attr, this.props.children);
}
}
module.exports = MenuList;

Просмотреть файл

@ -0,0 +1,11 @@
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
# vim: set filetype=python:
# 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/.
DevToolsModules(
'MenuButton.js',
'MenuItem.js',
'MenuList.js',
)

Просмотреть файл

@ -5,6 +5,7 @@
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
DIRS += [
'menu',
'reps',
'splitter',
'tabs',