@atlaskit/dropdown-menu
Version:
A dropdown menu displays a list of actions or options to a user.
331 lines (324 loc) • 15.9 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import _objectWithoutProperties from "@babel/runtime/helpers/objectWithoutProperties";
import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
var _excluded = ["ref", "aria-controls", "aria-expanded", "aria-haspopup"];
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { bind } from 'bind-event-listener';
import Button from '@atlaskit/button/new';
import { KEY_DOWN, KEY_ENTER, KEY_SPACE, KEY_TAB } from '@atlaskit/ds-lib/keycodes';
import mergeRefs from '@atlaskit/ds-lib/merge-refs';
import noop from '@atlaskit/ds-lib/noop';
import useControlledState from '@atlaskit/ds-lib/use-controlled';
import useFocus from '@atlaskit/ds-lib/use-focus-event';
import ExpandIcon from '@atlaskit/icon/core/migration/chevron-down';
import { fg } from '@atlaskit/platform-feature-flags';
import Popup from '@atlaskit/popup';
// eslint-disable-next-line @atlaskit/design-system/no-deprecated-imports
import { gridSize as gridSizeFn, layers } from '@atlaskit/theme/constants';
import FocusManager from './internal/components/focus-manager';
import MenuWrapper from './internal/components/menu-wrapper';
import SelectionStore from './internal/context/selection-store';
import useRegisterItemWithFocusManager from './internal/hooks/use-register-item-with-focus-manager';
import useGeneratedId, { PREFIX } from './internal/utils/use-generated-id';
var gridSize = gridSizeFn();
var MAX_HEIGHT = "calc(100vh - ".concat(gridSize * 2, "px)");
var opposites = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
start: 'end',
auto: 'auto',
end: 'start'
};
var getFallbackPlacements = function getFallbackPlacements(placement) {
var placementPieces = placement.split('-');
var mainAxis = placementPieces[0];
// Left, right and auto placements can rely on standard popper sliding behaviour
if (!['top', 'bottom'].includes(mainAxis)) {
return undefined;
}
// Top and bottom placements need to flip to the right/left to ensure
// long lists don't extend off the screen
else if (placementPieces.length === 2 && ['start', 'end'].includes(placementPieces[1])) {
var crossAxis = placementPieces[1];
return ["".concat(mainAxis), "".concat(mainAxis, "-").concat(opposites[crossAxis]), "".concat(opposites[mainAxis], "-").concat(crossAxis), "".concat(opposites[mainAxis]), "".concat(opposites[mainAxis], "-").concat(opposites[crossAxis]), 'auto'];
} else {
return ["".concat(mainAxis, "-start"), "".concat(mainAxis, "-end"), "".concat(opposites[mainAxis]), "".concat(opposites[mainAxis], "-start"), "".concat(opposites[mainAxis], "-end"), "auto"];
}
};
function isKeyboardEvent(event) {
return event !== null && (event instanceof KeyboardEvent || 'nativeEvent' in event && event.nativeEvent instanceof KeyboardEvent);
}
/**
* __Dropdown menu__
*
* A dropdown menu displays a list of actions or options to a user.
*
* - [Examples](https://atlassian.design/components/dropdown-menu/examples)
* - [Code](https://atlassian.design/components/dropdown-menu/code)
* - [Usage](https://atlassian.design/components/dropdown-menu/usage)
*/
var DropdownMenu = function DropdownMenu(_ref) {
var _ref$autoFocus = _ref.autoFocus,
autoFocus = _ref$autoFocus === void 0 ? false : _ref$autoFocus,
children = _ref.children,
_ref$defaultOpen = _ref.defaultOpen,
defaultOpen = _ref$defaultOpen === void 0 ? false : _ref$defaultOpen,
_ref$isLoading = _ref.isLoading,
isLoading = _ref$isLoading === void 0 ? false : _ref$isLoading,
isOpen = _ref.isOpen,
_ref$onOpenChange = _ref.onOpenChange,
onOpenChange = _ref$onOpenChange === void 0 ? noop : _ref$onOpenChange,
_ref$placement = _ref.placement,
placement = _ref$placement === void 0 ? 'bottom-start' : _ref$placement,
_ref$shouldFitContain = _ref.shouldFitContainer,
shouldFitContainer = _ref$shouldFitContain === void 0 ? false : _ref$shouldFitContain,
_ref$shouldFlip = _ref.shouldFlip,
shouldFlip = _ref$shouldFlip === void 0 ? true : _ref$shouldFlip,
_ref$shouldRenderToPa = _ref.shouldRenderToParent,
shouldRenderToParent = _ref$shouldRenderToPa === void 0 ? false : _ref$shouldRenderToPa,
returnFocusRef = _ref.returnFocusRef,
spacing = _ref.spacing,
statusLabel = _ref.statusLabel,
testId = _ref.testId,
_trigger = _ref.trigger,
_ref$zIndex = _ref.zIndex,
zIndex = _ref$zIndex === void 0 ? layers.modal() : _ref$zIndex,
label = _ref.label,
interactionName = _ref.interactionName,
strategy = _ref.strategy;
var _useControlledState = useControlledState(isOpen, function () {
return defaultOpen;
}),
_useControlledState2 = _slicedToArray(_useControlledState, 2),
isLocalOpen = _useControlledState2[0],
setLocalIsOpen = _useControlledState2[1];
var triggerRef = useRef(null);
var _useState = useState(false),
_useState2 = _slicedToArray(_useState, 2),
isTriggeredUsingKeyboard = _useState2[0],
setTriggeredUsingKeyboard = _useState2[1];
var id = useGeneratedId();
var itemRef = useRegisterItemWithFocusManager();
var fallbackPlacements = useMemo(function () {
return getFallbackPlacements(placement);
}, [placement]);
var handleTriggerClicked = useCallback(
// TODO: event is an `any` and is being cast incorrectly
// This means that the public type for `onOpenChange` is incorrect
// current: (event: React.MouseEvent | React.KeyboardEvent) => void;
// correct: (event: React.MouseEvent | KeyboardEvent) => void;
// https://product-fabric.atlassian.net/browse/DSP-4692
function (event) {
var newValue = !isLocalOpen;
var clientX = event.clientX,
clientY = event.clientY,
type = event.type,
detail = event.detail;
if (type === 'keydown') {
setTriggeredUsingKeyboard(true);
} else if (clientX === 0 || clientY === 0) {
// Hitting enter/space is registered as a click
// with both clientX and clientY === 0
setTriggeredUsingKeyboard(true);
} else if (detail === 0) {
// Fix for Safari. clientX and clientY !== 0 in Safari
setTriggeredUsingKeyboard(true);
} else {
var _itemRef$current;
// The trigger element must be focused to avoid problems with an incorrectly focused element after closing DropdownMenu
itemRef === null || itemRef === void 0 || (_itemRef$current = itemRef.current) === null || _itemRef$current === void 0 || _itemRef$current.focus();
setTriggeredUsingKeyboard(false);
}
setLocalIsOpen(newValue);
onOpenChange({
isOpen: newValue,
event: event
});
}, [isLocalOpen, setLocalIsOpen, onOpenChange, itemRef]);
var handleOnClose = useCallback(function (event, currentLevel) {
var _event$target$closest, _event$target;
var isTabOrEscapeKey = isKeyboardEvent(event) && (event.key === 'Tab' || event.key === 'Escape');
if (event !== null && !isTabOrEscapeKey && event.target instanceof HTMLElement && (_event$target$closest = (_event$target = event.target).closest) !== null && _event$target$closest !== void 0 && _event$target$closest.call(_event$target, "[id^=".concat(PREFIX, "] [aria-haspopup]"))) {
var _itemRef$current2;
// Check if it is within dropdown and it is a trigger button
// if it is a nested dropdown, clicking trigger won't close the dropdown
// Dropdown can be closed by pressing Escape, Tab or Shift + Tab
if (!currentLevel) {
return;
}
// if currentLevel is provided, we will compare with the given item's level
// when it is available and larger than currentLevel, we will proceed the close behavior
var toCloseLevel = (_itemRef$current2 = itemRef.current) === null || _itemRef$current2 === void 0 ? void 0 : _itemRef$current2.dataset['ds-Level'];
if (toCloseLevel && Number(toCloseLevel) < currentLevel) {
return;
}
}
// transfer focus to the element specified by ref
// if ref is not provided, use old behavior:
// focus on trigger when <Esc> or <Shift+Tab> is pressed
if (returnFocusRef) {
requestAnimationFrame(function () {
var _returnFocusRef$curre;
(_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 || _returnFocusRef$curre.focus();
});
} else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape')) {
requestAnimationFrame(function () {
var _itemRef$current3;
(_itemRef$current3 = itemRef.current) === null || _itemRef$current3 === void 0 || _itemRef$current3.focus();
});
} else if (triggerRef.current) {
var _event$target$closest2, _event$target2;
// Don't focus the trigger if the click was outside of the menu
var isClickOutsideMenu = (event === null || event === void 0 ? void 0 : event.target) instanceof HTMLElement && ((_event$target$closest2 = (_event$target2 = event.target).closest) === null || _event$target$closest2 === void 0 ? void 0 : _event$target$closest2.call(_event$target2, '[role="menu"]')) === null;
var shouldPreventFocus = isClickOutsideMenu && document.activeElement !== document.body &&
// except if clicking on the body
fg('platform_design_system_team_dropdown_return_focus');
if (!shouldPreventFocus) {
requestAnimationFrame(function () {
var _triggerRef$current;
(_triggerRef$current = triggerRef.current) === null || _triggerRef$current === void 0 || _triggerRef$current.focus();
});
}
}
var newValue = false;
setLocalIsOpen(newValue);
onOpenChange({
isOpen: newValue,
event: event
});
}, [itemRef, onOpenChange, returnFocusRef, setLocalIsOpen]);
var _useFocus = useFocus(),
isFocused = _useFocus.isFocused,
bindFocus = _useFocus.bindFocus;
// When a trigger is focused, we want to open the dropdown if
// the user presses the DownArrow
useEffect(function () {
// Only need to listen for keydown when focused
if (!isFocused) {
return noop;
}
// Being safe: we don't want to open the dropdown if it is already open
// Note: This shouldn't happen as the trigger should not be able to get focus
if (isLocalOpen) {
return noop;
}
return bind(window, {
type: 'keydown',
listener: function openOnKeyDown(e) {
var isNestedTriggerButton;
if (e.target instanceof HTMLElement) {
isNestedTriggerButton = e.target.closest("[id^=".concat(PREFIX, "] [aria-haspopup]"));
}
if (e.key === KEY_DOWN && !isNestedTriggerButton) {
// prevent page scroll
e.preventDefault();
handleTriggerClicked(e);
} else if ((e.code === KEY_SPACE || e.key === KEY_ENTER) && e.detail === 0) {
// This allows us to focus on the first element if the dropdown was triggered by a custom trigger with a custom onClick
setTriggeredUsingKeyboard(true);
} else if (e.key === KEY_TAB && isNestedTriggerButton) {
// This closes dropdown if it is a nested dropdown
handleOnClose(e);
}
}
});
}, [isFocused, isLocalOpen, handleTriggerClicked, handleOnClose]);
/*
* The Popup component requires either:
* - shouldFitContainer={true} and shouldRenderToParent={true | undefined}
* or
* - shouldFitContainer={false | undefined} and shouldRenderToParent={true | false | undefined}
*
* But not:
* - shouldFitContainer={true} and shouldRenderToParent={false}
*
* By only including either shouldFitContainer or shouldRenderToParent, we can ensure that the Popup component
* types are satisfied.
*/
var conditionalProps = shouldFitContainer ? {
shouldFitContainer: shouldFitContainer,
// When shouldFitContainer is true, `fixed` positions are not allowed
strategy: strategy !== 'fixed' ? strategy : undefined
} : {
shouldRenderToParent: shouldRenderToParent,
strategy: strategy
};
return /*#__PURE__*/React.createElement(SelectionStore, null, /*#__PURE__*/React.createElement(Popup, _extends({
id: isLocalOpen ? id : undefined,
shouldFlip: shouldFlip,
isOpen: isLocalOpen,
shouldReturnFocus:
// If returnFocusRef is provided, we **don't** want to return focus to the trigger.
// Otherwise, Popup will focus on the dropdown trigger after the `returnFocusRef` element is focused.
returnFocusRef === undefined,
onClose: handleOnClose,
zIndex: zIndex,
placement: placement,
fallbackPlacements: fallbackPlacements,
testId: testId && "".concat(testId, "--content"),
shouldUseCaptureOnOutsideClick: true
}, conditionalProps, {
shouldDisableFocusLock: true,
trigger: function trigger(_ref2) {
var ref = _ref2.ref,
ariaControls = _ref2['aria-controls'],
ariaExpanded = _ref2['aria-expanded'],
ariaHasPopup = _ref2['aria-haspopup'],
rest = _objectWithoutProperties(_ref2, _excluded);
if (typeof _trigger === 'function') {
return _trigger(_objectSpread(_objectSpread(_objectSpread({
'aria-controls': ariaControls,
'aria-expanded': ariaExpanded,
'aria-haspopup': ariaHasPopup
}, rest), bindFocus), {}, {
triggerRef: mergeRefs([ref, triggerRef, itemRef]),
isSelected: isLocalOpen,
onClick: handleTriggerClicked,
testId: testId && "".concat(testId, "--trigger")
}));
}
return /*#__PURE__*/React.createElement(Button, _extends({}, bindFocus, {
ref: mergeRefs([ref, triggerRef, itemRef]),
"aria-controls": ariaControls,
"aria-expanded": ariaExpanded,
"aria-haspopup": ariaHasPopup,
isSelected: isLocalOpen,
iconAfter: function iconAfter(iconProps) {
return /*#__PURE__*/React.createElement(ExpandIcon, _extends({}, iconProps, {
size: "small"
}));
},
onClick: handleTriggerClicked,
testId: testId && "".concat(testId, "--trigger"),
"aria-label": label,
interactionName: interactionName
}), _trigger);
},
content: function content(_ref3) {
var setInitialFocusRef = _ref3.setInitialFocusRef,
update = _ref3.update;
return /*#__PURE__*/React.createElement(FocusManager, {
onClose: handleOnClose
}, /*#__PURE__*/React.createElement(MenuWrapper, {
spacing: spacing,
maxHeight: MAX_HEIGHT,
maxWidth: shouldFitContainer ? undefined : 800,
onClose: handleOnClose,
onUpdate: update,
isLoading: isLoading,
statusLabel: statusLabel,
setInitialFocusRef: isTriggeredUsingKeyboard || autoFocus ? setInitialFocusRef : undefined,
shouldRenderToParent: shouldRenderToParent || shouldFitContainer,
isTriggeredUsingKeyboard: isTriggeredUsingKeyboard,
autoFocus: autoFocus,
testId: testId && "".concat(testId, "--menu-wrapper")
}, children));
}
})));
};
export default DropdownMenu;