@fluentui/react-northstar
Version:
A themable React component library.
508 lines (491 loc) • 22.2 kB
JavaScript
import _get from "lodash/get";
import _map from "lodash/map";
import _debounce from "lodash/debounce";
import _invoke from "lodash/invoke";
import _forEachRight from "lodash/forEachRight";
import { toolbarBehavior, toggleButtonBehavior, IS_FOCUSABLE_ATTRIBUTE } from '@fluentui/accessibility';
import { compose, getElementType, getFirstFocusable, useFluentContext, useAccessibility, useStyles, useTelemetry, useUnhandledProps } from '@fluentui/react-bindings';
import { EventListener } from '@fluentui/react-component-event-listener';
import { handleRef, Ref } from '@fluentui/react-component-ref';
import { MoreIcon } from '@fluentui/react-icons-northstar';
import * as customPropTypes from '@fluentui/react-proptypes';
import * as PropTypes from 'prop-types';
import * as React from 'react';
import { childrenExist, createShorthand, commonPropTypes } from '../../utils';
import { ToolbarCustomItem } from './ToolbarCustomItem';
import { ToolbarDivider } from './ToolbarDivider';
import { ToolbarItem } from './ToolbarItem';
import { ToolbarItemWrapper } from './ToolbarItemWrapper';
import { ToolbarItemIcon } from './ToolbarItemIcon';
import { ToolbarMenu } from './ToolbarMenu';
import { ToolbarMenuDivider } from './ToolbarMenuDivider';
import { ToolbarMenuItem } from './ToolbarMenuItem';
import { ToolbarMenuRadioGroup } from './ToolbarMenuRadioGroup';
import { ToolbarMenuRadioGroupWrapper } from './ToolbarMenuRadioGroupWrapper';
import { ToolbarRadioGroup } from './ToolbarRadioGroup';
import { ToolbarVariablesProvider } from './toolbarVariablesContext';
import { ToolbarMenuItemSubmenuIndicator } from './ToolbarMenuItemSubmenuIndicator';
import { ToolbarMenuItemIcon } from './ToolbarMenuItemIcon';
import { ToolbarMenuItemActiveIndicator } from './ToolbarMenuItemActiveIndicator';
import { ToolbarMenuContextProvider } from './toolbarMenuContext';
import { Box } from '../Box/Box';
var WAS_FOCUSABLE_ATTRIBUTE = 'data-was-focusable';
export var toolbarClassName = 'ui-toolbar';
/**
* A Toolbar is a container for grouping a set of controls, often action controls (e.g. buttons) or input controls (e.g. checkboxes).
*
* @accessibility
* * Implements [ARIA Toolbar](https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar) design pattern.
* @accessibilityIssues
* [Issue 988424: VoiceOver narrates selected for button in toolbar](https://bugs.chromium.org/p/chromium/issues/detail?id=988424)
* [In toolbars that can toggle items in a menu, VoiceOver narrates "1" for menuitemcheckbox/radio when checked.](https://github.com/microsoft/fluentui/issues/14064)
* [NVDA could narrate "checked" stated for radiogroup in toolbar #12678](https://github.com/nvaccess/nvda/issues/12678)
* [JAWS narrates wrong instruction message for radiogroup in toolbar #556](https://github.com/FreedomScientific/VFO-standards-support/issues/556)
* [JAWS could narrate "checked" stated for radiogroup in toolbar #557](https://github.com/FreedomScientific/VFO-standards-support/issues/557)
*/
export var Toolbar = /*#__PURE__*/function () {
var Toolbar = compose(function (props, ref, composeOptions) {
var context = useFluentContext();
var _useTelemetry = useTelemetry(composeOptions.displayName, context.telemetry),
setStart = _useTelemetry.setStart,
setEnd = _useTelemetry.setEnd;
setStart();
var accessibility = props.accessibility,
className = props.className,
children = props.children,
design = props.design,
getOverflowItems = props.getOverflowItems,
items = props.items,
overflow = props.overflow,
overflowItem = props.overflowItem,
overflowOpen = props.overflowOpen,
overflowSentinel = props.overflowSentinel,
styles = props.styles,
variables = props.variables;
var overflowContainerRef = React.useRef();
var overflowItemWrapperRef = React.useRef();
var overflowSentinelRef = React.useRef();
var offsetMeasureRef = React.useRef();
var containerRef = React.useRef();
// index of the last visible item in Toolbar, the rest goes to overflow menu
var lastVisibleItemIndex = React.useRef();
var animationFrameId = React.useRef();
var getA11Props = useAccessibility(accessibility, {
debugName: composeOptions.displayName,
rtl: context.rtl
});
var _useStyles = useStyles(composeOptions.displayName, {
className: toolbarClassName,
composeOptions: composeOptions,
mapPropsToStyles: function mapPropsToStyles() {
return {
overflowOpen: overflowOpen
};
},
mapPropsToInlineStyles: function mapPropsToInlineStyles() {
return {
className: className,
design: design,
styles: styles,
variables: variables
};
},
rtl: context.rtl,
unstable_props: props
}),
classes = _useStyles.classes;
var ElementType = getElementType(props);
var slotProps = composeOptions.resolveSlotProps(props);
var unhandledProps = useUnhandledProps(composeOptions.handledProps, props);
var hide = function hide(el) {
if (el.style.visibility === 'hidden') {
return;
}
if (context.target.activeElement === el || el.contains(context.target.activeElement)) {
if (containerRef.current) {
var firstFocusableItem = getFirstFocusable(containerRef.current, containerRef.current.firstElementChild);
if (firstFocusableItem) {
firstFocusableItem.focus();
}
}
}
el.style.visibility = 'hidden';
var wasFocusable = el.getAttribute(IS_FOCUSABLE_ATTRIBUTE);
if (wasFocusable) {
el.setAttribute(WAS_FOCUSABLE_ATTRIBUTE, wasFocusable);
}
el.setAttribute(IS_FOCUSABLE_ATTRIBUTE, 'false');
};
var show = function show(el) {
if (el.style.visibility !== 'hidden') {
return false;
}
el.style.visibility = '';
var wasFocusable = el.getAttribute(WAS_FOCUSABLE_ATTRIBUTE);
if (wasFocusable) {
el.setAttribute(IS_FOCUSABLE_ATTRIBUTE, wasFocusable);
el.removeAttribute(WAS_FOCUSABLE_ATTRIBUTE);
} else {
el.removeAttribute(IS_FOCUSABLE_ATTRIBUTE);
}
return true;
};
/**
* Checks if `item` overflows a `container`.
* TODO: check and fix all margin combination
*/
var isItemOverflowing = function isItemOverflowing(itemBoundingRect, containerBoundingRect) {
return itemBoundingRect.right > containerBoundingRect.right || itemBoundingRect.left < containerBoundingRect.left;
};
/**
* Checks if `item` would collide with eventual position of `overflowItem`.
*/
var wouldItemCollide = function wouldItemCollide($item, itemBoundingRect, overflowItemBoundingRect, containerBoundingRect) {
var actualWindow = context.target.defaultView;
var wouldCollide;
if (context.rtl) {
var itemLeftMargin = parseFloat(actualWindow.getComputedStyle($item).marginLeft) || 0;
wouldCollide = itemBoundingRect.left - overflowItemBoundingRect.width - itemLeftMargin < containerBoundingRect.left;
// console.log('Collision [RTL]', {
// wouldCollide,
// 'itemBoundingRect.left': itemBoundingRect.left,
// 'overflowItemBoundingRect.width': overflowItemBoundingRect.width,
// itemRightMargin: itemLeftMargin,
// sum: itemBoundingRect.left - overflowItemBoundingRect.width - itemLeftMargin,
// 'overflowContainerBoundingRect.left': containerBoundingRect.left,
// })
} else {
var itemRightMargin = parseFloat(actualWindow.getComputedStyle($item).marginRight) || 0;
wouldCollide = itemBoundingRect.right + overflowItemBoundingRect.width + itemRightMargin > containerBoundingRect.right;
// console.log('Collision', {
// wouldCollide,
// 'itemBoundingRect.right': itemBoundingRect.right,
// 'overflowItemBoundingRect.width': overflowItemBoundingRect.width,
// itemRightMargin,
// sum: itemBoundingRect.right + overflowItemBoundingRect.width + itemRightMargin,
// 'overflowContainerBoundingRect.right': containerBoundingRect.right,
// })
}
return wouldCollide;
};
/**
* Positions overflowItem next to lastVisible item
* TODO: consider overflowItem margin
*/
var setOverflowPosition = function setOverflowPosition($overflowItem, $lastVisibleItem, lastVisibleItemRect, containerBoundingRect, absolutePositioningOffset) {
var actualWindow = context.target.defaultView;
if ($lastVisibleItem) {
if (context.rtl) {
var lastVisibleItemMarginLeft = parseFloat(actualWindow.getComputedStyle($lastVisibleItem).marginLeft) || 0;
$overflowItem.style.right = containerBoundingRect.right - lastVisibleItemRect.left + lastVisibleItemMarginLeft + absolutePositioningOffset.horizontal + "px";
} else {
var lastVisibleItemRightMargin = parseFloat(actualWindow.getComputedStyle($lastVisibleItem).marginRight) || 0;
$overflowItem.style.left = lastVisibleItemRect.right - containerBoundingRect.left + lastVisibleItemRightMargin + absolutePositioningOffset.horizontal + "px";
}
} else {
// there is no last visible item -> position the overflow as the first item
lastVisibleItemIndex.current = -1;
if (context.rtl) {
$overflowItem.style.right = absolutePositioningOffset.horizontal + "px";
} else {
$overflowItem.style.left = absolutePositioningOffset.horizontal + "px";
}
}
};
var hideOverflowItems = function hideOverflowItems() {
var $overflowContainer = overflowContainerRef.current;
var $overflowItem = overflowItemWrapperRef.current;
var $overflowSentinel = overflowSentinelRef.current;
var $offsetMeasure = offsetMeasureRef.current;
if (!$overflowContainer || !$overflowItem || !$offsetMeasure) {
return;
}
// workaround: when resizing window with popup opened the container contents scroll for some reason
if (context.rtl) {
$overflowContainer.scrollTo(Number.MAX_SAFE_INTEGER, 0);
} else {
$overflowContainer.scrollTop = 0;
$overflowContainer.scrollLeft = 0;
}
var $items = $overflowContainer.children;
var overflowContainerBoundingRect = $overflowContainer.getBoundingClientRect();
var overflowItemBoundingRect = $overflowItem.getBoundingClientRect();
var offsetMeasureBoundingRect = $offsetMeasure.getBoundingClientRect();
// Absolute positioning offset
// Overflow menu is absolutely positioned relative to root slot
// If there is padding set on the root slot boundingClientRect computations use inner content box,
// but absolute position is relative to root slot's PADDING box.
// We compute absolute positioning offset
// By measuring position of an offsetMeasure element absolutely positioned to 0,0.
// TODO: replace by getComputedStyle('padding')
var absolutePositioningOffset = {
horizontal: context.rtl ? offsetMeasureBoundingRect.right - overflowContainerBoundingRect.right : overflowContainerBoundingRect.left - offsetMeasureBoundingRect.left,
vertical: overflowContainerBoundingRect.top - offsetMeasureBoundingRect.top
};
var isOverflowing = false;
var $lastVisibleItem;
var lastVisibleItemRect;
// check all items from the last one back
_forEachRight($items, function ($item, i) {
if ($item === $overflowItem || $item === $overflowSentinel) {
return true;
}
var itemBoundingRect = $item.getBoundingClientRect();
// if the item is out of the crop rectangle, hide it
if (isItemOverflowing(itemBoundingRect, overflowContainerBoundingRect)) {
isOverflowing = true;
// console.log('Overflow', i, {
// item: [itemBoundingRect.left, itemBoundingRect.right],
// crop: [
// overflowContainerBoundingRect.left,
// overflowContainerBoundingRect.right,
// overflowContainerBoundingRect.width,
// ],
// container: $overflowContainer,
// })
hide($item);
return true;
}
// if there is an overflow, check collision of remaining items with eventual overflow position
if (isOverflowing && !$lastVisibleItem && wouldItemCollide($item, itemBoundingRect, overflowItemBoundingRect, overflowContainerBoundingRect)) {
hide($item);
return true;
}
// Remember the last visible item
if (!$lastVisibleItem) {
$lastVisibleItem = $item;
lastVisibleItemRect = itemBoundingRect;
lastVisibleItemIndex.current = i;
}
return show($item); // exit the loop when first visible item is found
});
// if there is an overflow, position and show overflow item, otherwise hide it
if (isOverflowing || overflowOpen) {
$overflowItem.style.position = 'absolute';
setOverflowPosition($overflowItem, $lastVisibleItem, lastVisibleItemRect, overflowContainerBoundingRect, absolutePositioningOffset);
show($overflowItem);
} else {
lastVisibleItemIndex.current = items.length - 1;
hide($overflowItem);
}
_invoke(props, 'onOverflow', lastVisibleItemIndex.current + 1);
};
var collectOverflowItems = function collectOverflowItems() {
// console.log('getOverflowItems()', items.slice(lastVisibleItemIndex.current + 1))
return getOverflowItems ? getOverflowItems(lastVisibleItemIndex.current + 1) : items.slice(lastVisibleItemIndex.current + 1);
};
var getVisibleItems = function getVisibleItems() {
// console.log('allItems()', items)
var end = overflowOpen ? lastVisibleItemIndex.current + 1 : items.length;
// console.log('getVisibleItems()', items.slice(0, end))
return items.slice(0, end);
};
var handleWindowResize = _debounce(function (e) {
hideOverflowItems();
if (overflowOpen) {
_invoke(props, 'onOverflowOpenChange', e, Object.assign({}, props, {
overflowOpen: false
}));
}
}, 16);
var renderItems = function renderItems(items) {
return _map(items, function (item) {
var kind = _get(item, 'kind', 'item');
switch (kind) {
case 'divider':
return createShorthand(composeOptions.slots.divider, item, {
defaultProps: function defaultProps() {
return slotProps.divider;
}
});
case 'group':
return createShorthand(composeOptions.slots.group, item, {
defaultProps: function defaultProps() {
return slotProps.group;
}
});
case 'toggle':
return createShorthand(composeOptions.slots.toggle, item, {
defaultProps: function defaultProps() {
return slotProps.toggle;
}
});
case 'custom':
return createShorthand(composeOptions.slots.customItem, item, {
defaultProps: function defaultProps() {
return slotProps.customItem;
}
});
default:
return createShorthand(composeOptions.slots.item, item, {
defaultProps: function defaultProps() {
return slotProps.item;
}
});
}
});
};
var renderOverflowItem = function renderOverflowItem(overflowItem) {
return createShorthand(composeOptions.slots.overflowItem, overflowItem, {
defaultProps: function defaultProps() {
return slotProps.overflowItem;
},
overrideProps: function overrideProps(predefinedProps) {
var _predefinedProps$menu;
return {
menu: {
items: overflowOpen ? collectOverflowItems() : [],
popper: Object.assign({
positionFixed: true
}, (_predefinedProps$menu = predefinedProps.menu) == null ? void 0 : _predefinedProps$menu.popper)
},
menuOpen: overflowOpen,
onMenuOpenChange: function onMenuOpenChange(e, _ref) {
var menuOpen = _ref.menuOpen;
_invoke(props, 'onOverflowOpenChange', e, Object.assign({}, props, {
overflowOpen: menuOpen
}));
},
wrapper: {
ref: overflowItemWrapperRef
}
};
}
});
};
// renders a sentinel div that maintains the toolbar dimensions when the the overflow menu is open
// hidden elements are removed from the DOM
var renderOverflowSentinel = function renderOverflowSentinel() {
return /*#__PURE__*/React.createElement(Ref, {
innerRef: function innerRef(element) {
overflowSentinelRef.current = element;
}
}, Box.create(overflowSentinel, {
defaultProps: function defaultProps() {
return {
id: 'sentinel',
className: classes.overflowSentinel
};
}
}));
};
React.useEffect(function () {
var actualWindow = context.target.defaultView;
actualWindow.cancelAnimationFrame(animationFrameId.current);
// Heads up! There are cases (like opening a portal and rendering the Toolbar there immediately) when rAF is necessary
animationFrameId.current = actualWindow.requestAnimationFrame(function () {
hideOverflowItems();
});
return function () {
if (animationFrameId.current !== undefined) {
var _context$target$defau;
(_context$target$defau = context.target.defaultView) == null ? void 0 : _context$target$defau.cancelAnimationFrame(animationFrameId.current);
animationFrameId.current = undefined;
}
};
});
var element = overflow ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Ref, {
innerRef: function innerRef(node) {
containerRef.current = node;
handleRef(ref, node);
}
}, getA11Props.unstable_wrapWithFocusZone( /*#__PURE__*/React.createElement(ElementType, getA11Props('root', Object.assign({
className: classes.root
}, unhandledProps)), /*#__PURE__*/React.createElement("div", {
className: classes.overflowContainer,
ref: overflowContainerRef
}, /*#__PURE__*/React.createElement(ToolbarMenuContextProvider, {
value: {
slots: {
menu: composeOptions.slots.menu
}
}
}, /*#__PURE__*/React.createElement(ToolbarVariablesProvider, {
value: variables
}, childrenExist(children) ? children : renderItems(getVisibleItems()), overflowSentinel && renderOverflowSentinel(), renderOverflowItem(overflowItem)))), /*#__PURE__*/React.createElement("div", {
className: classes.offsetMeasure,
ref: offsetMeasureRef
})))), /*#__PURE__*/React.createElement(EventListener, {
listener: handleWindowResize,
target: context.target.defaultView,
type: "resize"
})) : /*#__PURE__*/React.createElement(Ref, {
innerRef: function innerRef(node) {
containerRef.current = node;
handleRef(ref, node);
}
}, getA11Props.unstable_wrapWithFocusZone( /*#__PURE__*/React.createElement(ElementType, getA11Props('root', Object.assign({
className: classes.root
}, unhandledProps)), /*#__PURE__*/React.createElement(ToolbarMenuContextProvider, {
value: {
slots: {
menu: composeOptions.slots.menu
}
}
}, /*#__PURE__*/React.createElement(ToolbarVariablesProvider, {
value: variables
}, childrenExist(children) ? children : renderItems(items))))));
setEnd();
return element;
}, {
className: toolbarClassName,
displayName: 'Toolbar',
slots: {
customItem: ToolbarCustomItem,
divider: ToolbarDivider,
item: ToolbarItem,
group: ToolbarRadioGroup,
toggle: ToolbarItem,
overflowItem: ToolbarItem,
menu: ToolbarMenu
},
slotProps: function slotProps() {
return {
toggle: {
accessibility: toggleButtonBehavior
},
overflowItem: {
icon: /*#__PURE__*/React.createElement(MoreIcon, {
outline: true
})
}
};
},
shorthandConfig: {
mappedProp: 'content'
},
handledProps: ['accessibility', 'as', 'children', 'className', 'content', 'design', 'getOverflowItems', 'items', 'onOverflow', 'onOverflowOpenChange', 'overflow', 'overflowItem', 'overflowOpen', 'overflowSentinel', 'styles', 'variables']
});
Toolbar.propTypes = Object.assign({}, commonPropTypes.createCommon(), {
items: customPropTypes.collectionShorthandWithKindProp(['divider', 'item', 'group', 'toggle', 'custom']),
overflow: PropTypes.bool,
overflowOpen: PropTypes.bool,
overflowSentinel: customPropTypes.shorthandAllowingChildren,
overflowItem: customPropTypes.shorthandAllowingChildren,
onOverflow: PropTypes.func,
onOverflowOpenChange: PropTypes.func,
getOverflowItems: PropTypes.func
});
Toolbar.defaultProps = {
accessibility: toolbarBehavior,
items: [],
overflowItem: {},
overflowSentinel: {}
};
Toolbar.CustomItem = ToolbarCustomItem;
Toolbar.Divider = ToolbarDivider;
Toolbar.Item = ToolbarItem;
Toolbar.ItemWrapper = ToolbarItemWrapper;
Toolbar.ItemIcon = ToolbarItemIcon;
Toolbar.Menu = ToolbarMenu;
Toolbar.MenuDivider = ToolbarMenuDivider;
Toolbar.MenuItem = ToolbarMenuItem;
Toolbar.MenuItemIcon = ToolbarMenuItemIcon;
Toolbar.MenuItemSubmenuIndicator = ToolbarMenuItemSubmenuIndicator;
Toolbar.MenuItemActiveIndicator = ToolbarMenuItemActiveIndicator;
Toolbar.MenuRadioGroup = ToolbarMenuRadioGroup;
Toolbar.MenuRadioGroupWrapper = ToolbarMenuRadioGroupWrapper;
Toolbar.RadioGroup = ToolbarRadioGroup;
return Toolbar;
}();
//# sourceMappingURL=Toolbar.js.map