UNPKG

@atlaskit/dropdown-menu

Version:

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

310 lines (303 loc) 13 kB
import _extends from "@babel/runtime/helpers/extends"; 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'; const gridSize = gridSizeFn(); const MAX_HEIGHT = `calc(100vh - ${gridSize * 2}px)`; const opposites = { top: 'bottom', bottom: 'top', left: 'right', right: 'left', start: 'end', auto: 'auto', end: 'start' }; const getFallbackPlacements = placement => { const placementPieces = placement.split('-'); const 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])) { const crossAxis = placementPieces[1]; return [`${mainAxis}`, `${mainAxis}-${opposites[crossAxis]}`, `${opposites[mainAxis]}-${crossAxis}`, `${opposites[mainAxis]}`, `${opposites[mainAxis]}-${opposites[crossAxis]}`, 'auto']; } else { return [`${mainAxis}-start`, `${mainAxis}-end`, `${opposites[mainAxis]}`, `${opposites[mainAxis]}-start`, `${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) */ const DropdownMenu = ({ autoFocus = false, children, defaultOpen = false, isLoading = false, isOpen, onOpenChange = noop, placement = 'bottom-start', shouldFitContainer = false, shouldFlip = true, shouldRenderToParent = false, returnFocusRef, spacing, statusLabel, testId, trigger, zIndex = layers.modal(), label, interactionName, strategy }) => { const [isLocalOpen, setLocalIsOpen] = useControlledState(isOpen, () => defaultOpen); const triggerRef = useRef(null); const [isTriggeredUsingKeyboard, setTriggeredUsingKeyboard] = useState(false); const id = useGeneratedId(); const itemRef = useRegisterItemWithFocusManager(); const fallbackPlacements = useMemo(() => getFallbackPlacements(placement), [placement]); const 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 event => { const newValue = !isLocalOpen; const { clientX, clientY, type, detail } = event; 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 ? void 0 : (_itemRef$current = itemRef.current) === null || _itemRef$current === void 0 ? void 0 : _itemRef$current.focus(); setTriggeredUsingKeyboard(false); } setLocalIsOpen(newValue); onOpenChange({ isOpen: newValue, event }); }, [isLocalOpen, setLocalIsOpen, onOpenChange, itemRef]); const handleOnClose = useCallback((event, currentLevel) => { var _event$target$closest, _event$target; const 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^=${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 const 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(() => { var _returnFocusRef$curre; (_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 ? void 0 : _returnFocusRef$curre.focus(); }); } else if (isKeyboardEvent(event) && (event.key === 'Tab' && event.shiftKey || event.key === 'Escape')) { requestAnimationFrame(() => { var _itemRef$current3; (_itemRef$current3 = itemRef.current) === null || _itemRef$current3 === void 0 ? 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 const 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; const shouldPreventFocus = isClickOutsideMenu && document.activeElement !== document.body && // except if clicking on the body fg('platform_design_system_team_dropdown_return_focus'); if (!shouldPreventFocus) { requestAnimationFrame(() => { var _triggerRef$current; (_triggerRef$current = triggerRef.current) === null || _triggerRef$current === void 0 ? void 0 : _triggerRef$current.focus(); }); } } const newValue = false; setLocalIsOpen(newValue); onOpenChange({ isOpen: newValue, event }); }, [itemRef, onOpenChange, returnFocusRef, setLocalIsOpen]); const { isFocused, bindFocus } = useFocus(); // When a trigger is focused, we want to open the dropdown if // the user presses the DownArrow useEffect(() => { // 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) { let isNestedTriggerButton; if (e.target instanceof HTMLElement) { isNestedTriggerButton = e.target.closest(`[id^=${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. */ const conditionalProps = shouldFitContainer ? { shouldFitContainer, // When shouldFitContainer is true, `fixed` positions are not allowed strategy: strategy !== 'fixed' ? strategy : undefined } : { shouldRenderToParent, 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 && `${testId}--content`, shouldUseCaptureOnOutsideClick: true }, conditionalProps, { shouldDisableFocusLock: true, trigger: ({ ref, 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-haspopup': ariaHasPopup, // DSP-13312 TODO: remove spread props in future major release ...rest }) => { if (typeof trigger === 'function') { return trigger({ 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-haspopup': ariaHasPopup, ...rest, ...bindFocus, triggerRef: mergeRefs([ref, triggerRef, itemRef]), isSelected: isLocalOpen, onClick: handleTriggerClicked, testId: testId && `${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: iconProps => /*#__PURE__*/React.createElement(ExpandIcon, _extends({}, iconProps, { size: "small" })), onClick: handleTriggerClicked, testId: testId && `${testId}--trigger`, "aria-label": label, interactionName: interactionName }), trigger); }, content: ({ setInitialFocusRef, update }) => /*#__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 && `${testId}--menu-wrapper` }, children)) }))); }; export default DropdownMenu;