@atlaskit/dropdown-menu
Version:
A dropdown menu displays a list of actions or options to a user.
310 lines (303 loc) • 13 kB
JavaScript
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;