UNPKG

@atlaskit/dropdown-menu

Version:

A dropdown menu displays a list of actions or options to a user.

331 lines (324 loc) 15.9 kB
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;