UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

488 lines (487 loc) 25.9 kB
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 }))))))); } }