azure-devops-ui
Version:
React components for building web UI in Azure DevOps
488 lines (487 loc) • 25.9 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./Menu.css";
import "./MenuButton.css";
import * as React from "react";
import { ObservableCollection, ObservableLike, ObservableValue } from '../../Core/Observable';
import { Callout } from '../../Callout';
import { Checkbox } from '../../Checkbox';
import { FocusWithin } from '../../FocusWithin';
import { FocusZone, FocusZoneContext, FocusZoneDirection, FocusZoneKeyStroke } from '../../FocusZone';
import { Icon, IconSize } from '../../Icon';
import { List } from '../../List';
import { MouseWithin } from '../../MouseWithin';
import { Observer } from '../../Observer';
import { css, getSafeId, getSafeIdSelector, isArrowKey, KeyCode, preventDefault, setFocusVisible } from '../../Util';
import { Location } from '../../Utilities/Position';
import { ArrayItemProvider } from '../../Utilities/Provider';
import { MenuCell, MenuItemType } from "./Menu.Props";
/**
* Arrange the items into groups and put separators between them and headings above them as needed.
*
* @param items Menu items with optional order and groupKey properties
* @param groupInfo Optional list of menu groups
*/
export function groupMenuItems(items, groupInfo) {
const groupMap = {};
let maxGroupRank = 0;
const ungroupedItems = [];
const groups = groupInfo || [];
// gather known groups
if (groups.length > 0) {
maxGroupRank = groups.reduce((max, g) => (g.rank || 0 > max ? g.rank : max), 0) || 0;
for (const g of groups) {
groupMap[g.key] = {
key: g.key,
rank: g.rank === undefined ? ++maxGroupRank : g.rank,
items: []
};
}
}
// put all menu items in groups
for (const i of items) {
if (i.groupKey) {
if (groupMap[i.groupKey]) {
groupMap[i.groupKey].items.push(i);
}
else {
groupMap[i.groupKey] = {
key: i.groupKey,
rank: ++maxGroupRank,
items: [i]
};
}
}
else {
ungroupedItems.push(i);
}
}
// sort the groups
const groupList = Object.keys(groupMap).map(n => groupMap[n]);
groupList.sort((a, b) => (a.rank || Number.MAX_VALUE) - (b.rank || Number.MAX_VALUE));
// add ungrouped items to end of group list
groupList.push({
key: "ungrouped",
rank: ++maxGroupRank,
items: ungroupedItems
});
// remove dividers from the beginning and end of each group
groupList.forEach(g => {
const array = g.items;
while (array.length > 0 && array[0].itemType === MenuItemType.Divider) {
array.shift();
}
while (array.length > 0 && array[array.length - 1].itemType === MenuItemType.Divider) {
array.pop();
}
});
// merge the groups into the final array
items = [];
let first = true;
for (const g of groupList) {
if (g.items.length === 0) {
continue;
}
// add the separator or header for the top of the group
if (!first) {
items.push({
id: `divider_${g.key}`,
itemType: MenuItemType.Divider
});
}
if (first) {
first = false;
}
items = items.concat(g.items);
}
return items;
}
class MenuItemProvider extends ArrayItemProvider {
constructor(menuItems, menuGroups) {
super(menuItems);
this.positions = [];
let derivedItems = [];
// Process the set of menu items.
if (menuItems) {
let shouldGroupMenuItems = false;
let shouldSortMenuItems = false;
let lastItemType = MenuItemType.Divider;
let dividerItem;
for (const menuItem of menuItems) {
// Exclude hidden items
if (menuItem.hidden) {
continue;
}
// Don't allow multiple dividers to render next to each other.
if (menuItem.itemType === MenuItemType.Divider) {
if (menuItem.itemType === lastItemType) {
continue;
}
dividerItem = menuItem;
}
else {
if (dividerItem) {
derivedItems.push(dividerItem);
dividerItem = undefined;
}
derivedItems.push(menuItem);
}
lastItemType = menuItem.itemType || MenuItemType.Normal;
// If the item is ranked or grouped we need to sort and group them.
shouldGroupMenuItems = !!menuItem.groupKey || shouldGroupMenuItems;
shouldSortMenuItems = menuItem.rank >= 0 || shouldSortMenuItems;
}
if (shouldSortMenuItems) {
derivedItems.sort((a, b) => {
const aRank = a.rank || Number.MAX_VALUE;
const bRank = b.rank || Number.MAX_VALUE;
return aRank - bRank;
});
}
if (shouldGroupMenuItems) {
derivedItems = groupMenuItems(derivedItems, menuGroups);
}
}
// Update the items to be the derived items.
this.items = derivedItems;
}
// Custom getCount to support excluding the decorative items from the count.
getCount() {
if (this.count === undefined) {
this.count = 0;
for (const menuItem of this.items) {
if (menuItem.itemType === MenuItemType.Divider || menuItem.itemType === MenuItemType.Header) {
this.positions.push(-1);
}
else {
this.positions.push(++this.count);
}
}
}
return this.count;
}
getItem(index) {
return this.items[index];
}
getPosition(index) {
if (!this.positions.length) {
this.getCount();
}
return this.positions[index];
}
}
export class Menu extends React.Component {
constructor(props) {
super(props);
this.containerElement = React.createRef();
this.expandItem = (menuItem, expanded) => {
if (!menuItem && this.state.expandedIndex.value !== -1) {
menuItem = this.itemProvider.getItem(this.state.expandedIndex.value);
}
if (menuItem && menuItem.subMenuProps) {
for (let index = 0; index < this.itemProvider.length; index++) {
if (menuItem === this.itemProvider.getItem(index)) {
if (expanded) {
this.state.expandedIndex.value = index;
}
else {
this.state.expandedIndex.value = -1;
}
break;
}
}
}
};
this.focus = () => {
if (this.containerElement.current) {
this.containerElement.current.focus();
}
};
this.getParent = () => {
return this.props.parentMenu;
};
this.onActivate = (menuItem, event) => {
if (this.props.onActivate) {
this.props.onActivate(menuItem, event);
}
};
this.renderMenuItem = (index, menuItem, details) => {
const { onFocusItem } = details;
const menuItemDetails = {
expandedIndex: this.state.expandedIndex,
menu: this,
menuProps: this.props,
onActivate: this.onActivate,
onFocusItem: onFocusItem,
position: this.itemProvider.getPosition(index),
setSize: this.itemProvider.getCount()
};
if (menuItem.renderMenuItem) {
return menuItem.renderMenuItem(index, menuItem, menuItemDetails);
}
const key = menuItem.id;
switch (menuItem.itemType) {
case MenuItemType.Divider:
return MenuDivider(index, menuItem);
case MenuItemType.Header:
return MenuHeader(index, menuItem);
default:
return React.createElement(MenuItem, { key: key, index: index, menuItem: menuItem, details: menuItemDetails });
}
};
this.state = {
expandedIndex: new ObservableValue(-1)
};
}
render() {
return (React.createElement(Observer, { items: this.props.items }, (props) => {
this.itemProvider = new MenuItemProvider(props.items, this.props.groups);
return this.renderList();
}));
}
renderList() {
return (React.createElement("div", { className: "bolt-menu-container no-outline", ref: this.containerElement, tabIndex: -1 }, this.itemProvider.length > 0 && (React.createElement(React.Fragment, null,
React.createElement("div", { className: "bolt-menu-spacer", onMouseDown: preventDefault }),
React.createElement(List, { ariaLabel: this.props.ariaLabel, className: css(this.props.className, "bolt-menu"), columnCount: 7, focuszoneProps: null, id: this.props.id, itemProvider: this.itemProvider, renderRow: this.renderMenuItem, role: "menu", virtualize: false }),
React.createElement("div", { className: "bolt-menu-spacer", onMouseDown: preventDefault })))));
}
}
export function MenuDivider(index, menuItem) {
return (React.createElement("tr", { "aria-hidden": "true", className: css(menuItem.className, "bolt-menuitem-row bolt-list-row bolt-menuitem-divider"), key: menuItem.id || "divider-" + index, onMouseDown: preventDefault },
React.createElement("td", { className: "bolt-menuitem-cell bolt-list-cell" }),
React.createElement("td", { className: "bolt-menuitem-cell bolt-list-cell bolt-menuitem-divider-column", colSpan: 5 },
React.createElement("div", { className: "bolt-menuitem-divider-content" })),
React.createElement("td", { className: "bolt-menuitem-cell bolt-list-cell" })));
}
export function MenuHeader(index, menuItem) {
return (React.createElement("tr", { className: css(menuItem.className, "bolt-menuitem-row bolt-list-row bolt-menuitem-header"), key: menuItem.id || "header-" + index, onMouseDown: preventDefault, role: "separator" },
React.createElement("td", { className: "bolt-menuitem-cell bolt-list-cell" }),
React.createElement("td", { className: "bolt-menuitem-cell bolt-list-cell", colSpan: 3 },
React.createElement("div", { className: "bolt-menuitem-cell-content bolt-menuitem-cell-text" }, menuItem.text)),
React.createElement("td", { className: "bolt-menuitem-cell bolt-list-cell", colSpan: 3 })));
}
export class MenuItem extends React.Component {
constructor() {
super(...arguments);
this.localKeyStroke = false;
this.expanded = false;
this.element = React.createRef();
this.handleClick = (event) => {
const menuItem = this.props.menuItem;
if (menuItem.disabled) {
event.preventDefault();
}
else if (!this.expanded) {
let ownerResponse = false;
// If the menu owner supplied a handler, we will get feedback from them before doing
// default processing on the menu item.
if (menuItem.onActivate) {
ownerResponse = menuItem.onActivate(menuItem, event);
}
// If the owner specifically returned true, we will not perform any defaults.
if (!ownerResponse) {
if (!menuItem.href) {
event.preventDefault();
}
// For menus with sub-menus we will expand it on activation. For other menu items
// they are executed.
if (menuItem.subMenuProps) {
this.props.details.menu.expandItem(menuItem, true);
}
else if (menuItem.href) {
this.props.details.onActivate(menuItem, event);
}
else if (menuItem.checked === undefined || menuItem.readonly) {
this.props.details.onActivate(menuItem, event);
}
}
}
};
// If the click handler doesn't return false explicitly close dismiss the menu.
this.onClick = (event) => {
if (!event.defaultPrevented) {
this.handleClick(event);
}
};
this.onDismissSubMenu = (dismissAll) => {
if (!dismissAll && this.element.current) {
this.props.details.menu.expandItem(this.props.menuItem, false);
}
};
this.onExpandedChange = (expandedIndex) => {
return (this.expanded && expandedIndex !== this.props.index) || (!this.expanded && expandedIndex === this.props.index);
};
this.onFocus = (event) => {
if (this.element.current === document.activeElement) {
this.props.details.onFocusItem(this.props.index, event);
}
};
// Handle the keydown to expand the menu.
this.onKeyDown = (event) => {
this.localKeyStroke = true;
if (!event.defaultPrevented) {
const menuItem = this.props.menuItem;
if (event.which === KeyCode.tab || event.which === KeyCode.space) {
event.preventDefault();
}
else if (event.which === KeyCode.rightArrow && menuItem.subMenuProps) {
event.preventDefault();
this.props.details.menu.expandItem(menuItem, true);
}
}
};
// Translate the space and enter keys into onClick event for a menuItem.
this.onKeyUp = (event) => {
// If we get focus while a key is down we will get the keyup. We dont want
// to process this key, it needs to originate from us.
if (!this.localKeyStroke) {
return;
}
if (!event.defaultPrevented) {
if (event.which === KeyCode.enter || event.which === KeyCode.space) {
this.handleClick(event);
}
}
};
this.onMouseDown = (event) => {
if (!event.defaultPrevented) {
const menuItem = this.props.menuItem;
if (menuItem.disabled || this.props.details.expandedIndex.value === this.props.index) {
event.preventDefault();
}
}
};
// If you hover over a menu with a submenu we will open it after a short delay
// or stop the closing timeout.
this.onMouseEnter = () => {
if (!this.props.menuItem.disabled) {
if (this.element.current) {
this.element.current.focus();
}
this.props.details.menu.expandItem(this.props.menuItem, true);
setFocusVisible(false);
}
};
// If you leave the menu item and sub-menu we will close the menu after a short delay
// or stop the open timeout.
this.onMouseLeave = () => {
this.onDismissSubMenu(false);
};
}
render() {
const { index, menuItem, details } = this.props;
const { menu, position, setSize } = details;
const { ariaLabel, checked, className, disabled, href, iconProps, readonly, secondaryText, subMenuProps, target } = menuItem;
let { id, rel, text } = menuItem;
// If this is a link menu item we will use an anchor otherwise a plain div.
const CellType = href ? "div" : "td";
const RowType = href ? "a" : "tr";
// If the menu item is a link is targetting an external window or tab and no explicit rel
// attribute was supplied we will set noopener.
if (href && target && !rel) {
rel = "noopener";
}
return (React.createElement(Observer, { checked: checked, expandedIndex: { observableValue: this.props.details.expandedIndex, filter: this.onExpandedChange } }, (props) => {
this.expanded = props.expandedIndex === index;
return (React.createElement(MouseWithin, { enterDelay: 250, leaveDelay: 250, onMouseEnter: this.onMouseEnter, onMouseLeave: this.onMouseLeave }, (mouseWithinEvents) => (React.createElement(FocusZoneContext.Consumer, null, rowContext => (React.createElement(FocusWithin, { onFocus: this.onFocus }, (focusStatus) => (React.createElement(FocusZone, { direction: FocusZoneDirection.Horizontal },
React.createElement(RowType, { "aria-label": ariaLabel, "aria-checked": props.checked === true || undefined, "aria-controls": this.expanded && subMenuProps ? getSafeId(subMenuProps.id) : undefined, "aria-disabled": disabled ? "true" : undefined, "aria-expanded": subMenuProps ? this.expanded : undefined, "aria-haspopup": subMenuProps ? true : undefined, "aria-posinset": position, "aria-setsize": setSize, className: css(className, "bolt-menuitem-row bolt-list-row bolt-menuitem-row-normal cursor-pointer", disabled && "disabled", this.expanded && "expanded", focusStatus.hasFocus && "focused"), "data-focuszone": disabled ? undefined : rowContext.focuszoneId, href: href, id: getSafeId(id), role: props.checked !== undefined ? "menuitemcheckbox" : "menuitem", onBlur: focusStatus.onBlur, onClick: this.onClick, onFocus: focusStatus.onFocus, onKeyDown: this.onKeyDown, onKeyUp: this.onKeyUp, onMouseDown: this.onMouseDown, onMouseEnter: mouseWithinEvents.onMouseEnter, onMouseLeave: mouseWithinEvents.onMouseLeave, ref: this.element, rel: rel, tabIndex: disabled ? undefined : -1, target: target },
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" },
React.createElement("div", { className: "bolt-menuitem-cell-content flex-row" })),
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" }, props.checked !== undefined &&
((menuItem.renderMenuCell &&
menuItem.renderMenuCell(MenuCell.State, menuItem, details)) || (React.createElement("div", { className: "bolt-menuitem-cell-content bolt-menuitem-cell-state flex-row" }, readonly === true ? (Icon({
className: css(!props.checked && "invisible"),
iconName: "CheckMark"
})) : (React.createElement(Checkbox, { checked: props.checked, disabled: disabled, excludeFocusZone: true, excludeTabStop: true, onChange: this.onClick })))))),
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" }, (menuItem.renderMenuCell && menuItem.renderMenuCell(MenuCell.Icon, menuItem, details)) ||
(iconProps && (React.createElement("div", { className: "bolt-menuitem-cell-content bolt-menuitem-cell-icon flex-row" }, Icon(iconProps))))),
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" }, (menuItem.renderMenuCell &&
menuItem.renderMenuCell(MenuCell.PrimaryText, menuItem, details)) || (React.createElement("div", { id: getSafeId(id + "-text"), className: "bolt-menuitem-cell-content bolt-menuitem-cell-text flex-row" }, text ? (React.createElement(React.Fragment, null,
" ",
text,
" ")) : (React.createElement("div", null, "\u00A0"))))),
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" }, (menuItem.renderMenuCell &&
menuItem.renderMenuCell(MenuCell.SecondaryText, menuItem, details)) ||
(secondaryText && (React.createElement("div", { className: "bolt-menuitem-cell-content bolt-menuitem-cell-secondary flex-row" }, secondaryText)))),
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" }, (menuItem.renderMenuCell &&
menuItem.renderMenuCell(MenuCell.Action, menuItem, details)) ||
(subMenuProps && (React.createElement("div", { className: "bolt-menuitem-cell-content bolt-menuitem-cell-submenu flex-row" },
Icon({ iconName: "ChevronRightMed", size: IconSize.small }),
this.expanded && this.element.current && (React.createElement(ContextualMenu, { anchorElement: this.element.current, anchorOffset: { horizontal: 0, vertical: -8 }, anchorOrigin: { horizontal: Location.end, vertical: Location.start }, subMenu: true, menuOrigin: { horizontal: Location.start, vertical: Location.start }, menuProps: subMenuProps, onActivate: this.props.details.onActivate, onDismiss: this.onDismissSubMenu, parentMenu: menu })))))),
React.createElement(CellType, { className: "bolt-menuitem-cell bolt-list-cell" },
React.createElement("div", { className: "bolt-menuitem-cell-content flex-row" })))))))))));
}));
}
}
export class ContextualMenu extends React.Component {
constructor() {
super(...arguments);
this.calloutRef = React.createRef();
this.onDismiss = () => {
if (this.props.onDismiss) {
this.props.onDismiss(false);
}
};
this.onKeyDown = (event) => {
if (!event.defaultPrevented) {
if (event.which === KeyCode.escape || event.which === KeyCode.tab || (event.which === KeyCode.leftArrow && this.props.subMenu)) {
event.preventDefault();
if (this.props.onDismiss) {
this.props.onDismiss(false);
}
}
}
};
this.onActivate = (menuItem, event) => {
if (this.props.menuProps.onActivate) {
this.props.menuProps.onActivate(menuItem, event);
}
if (this.props.onActivate) {
this.props.onActivate(menuItem, event);
}
if (this.props.onDismiss) {
this.props.onDismiss(true);
}
};
this.preprocessKeyStroke = (event) => {
if (isArrowKey(event)) {
return FocusZoneKeyStroke.IgnoreParents;
}
return FocusZoneKeyStroke.IgnoreNone;
};
}
render() {
let defaultActiveElement = ".bolt-menu-container";
// Determine which element should be the first to get focus.
// Headers may be the first row and they wont take focus.
let items = ObservableLike.getValue(this.props.menuProps.items);
// Need slice() because order of elements matters in ObservableCollection
if (this.props.menuProps.items instanceof ObservableCollection) {
items = items.slice();
}
const sortedItems = items.sort((a, b) => {
return (a.rank || Number.MAX_VALUE) - (b.rank || Number.MAX_VALUE);
});
for (let menuIndex = 0; menuIndex < sortedItems.length; menuIndex++) {
if (sortedItems[menuIndex].itemType === MenuItemType.Normal || sortedItems[menuIndex].itemType === undefined) {
const menuItemId = sortedItems[menuIndex].id;
if (!menuItemId || sortedItems[menuIndex].disabled) {
continue;
}
defaultActiveElement = getSafeIdSelector(menuItemId);
break;
}
}
return (React.createElement(Observer, { menuItems: {
observableValue: this.props.menuProps.items
} }, () => (React.createElement(Callout, { ref: this.calloutRef, anchorElement: this.props.anchorElement, anchorOffset: this.props.anchorOffset, anchorOrigin: this.props.anchorOrigin, anchorPoint: this.props.anchorPoint, blurDismiss: true, calloutOrigin: this.props.menuOrigin, className: this.props.className, contentClassName: css("bolt-contextual-menu flex-column custom-scrollbar depth-8", this.props.subMenu && "bolt-contextual-submenu"), contentShadow: true, onDismiss: this.onDismiss, fixedLayout: this.props.fixedLayout, focuszoneProps: {
defaultActiveElement: defaultActiveElement,
direction: FocusZoneDirection.Vertical,
focusOnMount: true,
preprocessKeyStroke: this.preprocessKeyStroke,
circularNavigation: true
}, id: this.props.menuProps.id + "-callout", portalProps: { className: "bolt-menu-portal" }, updateLayout: true },
React.createElement("div", { className: "bolt-contextualmenu-container", onKeyDown: this.onKeyDown },
React.createElement(Menu, Object.assign({}, this.props.menuProps, { onActivate: this.onActivate, parentMenu: this.props.parentMenu })))))));
}
}