@atlaskit/dropdown-menu
Version:
A dropdown menu displays a list of actions or options to a user.
305 lines (291 loc) • 11.6 kB
JavaScript
/* dropdown-menu-top-layer.tsx generated by @compiled/babel-plugin v3.0.2 */
import _extends from "@babel/runtime/helpers/extends";
import "./dropdown-menu-top-layer.compiled.css";
import * as React from 'react';
import { ax, ix } from "@compiled/react/runtime";
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { bind } from 'bind-event-listener';
import Button from '@atlaskit/button/default/button';
import { KEY_DOWN } from '@atlaskit/ds-lib/keycodes';
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/chevron-down';
import MenuGroup from '@atlaskit/menu/menu-group';
import Spinner from '@atlaskit/spinner/spinner';
import { getAriaForTrigger } from '@atlaskit/top-layer/get-aria-for-trigger';
import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map/index';
import { Popover } from '@atlaskit/top-layer/popover/popover';
import { PopoverSurface } from '@atlaskit/top-layer/popover-surface';
import { useAnchoredPopover } from '@atlaskit/top-layer/use-anchored-popover';
import { isAtCurrentMenuLevel } from '@atlaskit/top-layer/is-at-current-menu-level';
import { useArrowNavigation } from '@atlaskit/top-layer/use-arrow-navigation/use-arrow-navigation';
import { usePopoverId } from '@atlaskit/top-layer/use-popover-id';
import SelectionStore from './internal/context/selection-store';
const MAX_HEIGHT = `calc(100vh - 16px)`;
const styles = {
spinnerContainer: "_1e0c1txw _1ul91lit _1bah1h6o _1q51v47k _y4tiv47k _85i5v47k _bozgv47k"
};
/**
* Event types produced by trigger interactions.
*
* - `React.MouseEvent<Element>`: from the trigger's `onClick` handler
* - `React.KeyboardEvent<Element>`: from the trigger's `onClick` when activated via keyboard
* - `KeyboardEvent`: native event from the ArrowDown `bind(window, ...)` listener
*/
function getRootMenuPopover({
menu
}) {
var _menu$parentElement;
const parentMenu = (_menu$parentElement = menu.parentElement) === null || _menu$parentElement === void 0 ? void 0 : _menu$parentElement.closest('[popover][role="menu"]');
if (!parentMenu) {
return menu;
}
return getRootMenuPopover({
menu: parentMenu
});
}
/**
* Loading indicator for the dropdown menu.
*/
function LoadingIndicator({
statusLabel = 'Loading',
testId
}) {
return /*#__PURE__*/React.createElement("div", {
role: "menuitem",
className: ax([styles.spinnerContainer])
}, /*#__PURE__*/React.createElement(Spinner, {
size: "small",
label: statusLabel,
testId: testId
}));
}
/**
* Top-layer implementation of DropdownMenu.
*
* Replaces the legacy `@atlaskit/popup` + `@atlaskit/portal` + `@atlaskit/layering` pipeline
* with the native Popover API via `@atlaskit/top-layer`.
*
* What is no longer needed:
* - Portal: top layer handles stacking natively
* - FocusLock / react-focus-lock: popover=auto provides light dismiss
* - z-index: top layer is always above everything
* - FocusManager (ref registration): replaced by DOM-query-based `useArrowNavigation`
* - handle-focus.tsx: replaced by `useArrowNavigation`
* - Layering context: top layer nesting is handled by the browser
* - Fallback placements / Popper: CSS Anchor Positioning handles positioning
*/
function DropdownMenuTopLayer({
children,
defaultOpen = false,
isLoading = false,
isOpen: isOpenProp,
onOpenChange = noop,
placement = 'bottom-start',
shouldFitContainer = false,
returnFocusRef,
spacing,
statusLabel,
testId,
trigger,
label,
interactionName,
menuLabel
}) {
var _ref;
const [isLocalOpen, setLocalIsOpen] = useControlledState(isOpenProp, () => defaultOpen);
const triggerRef = useRef(null);
const popoverRef = useRef(null);
const popoverId = usePopoverId();
const topLayerPlacement = useMemo(() => fromLegacyPlacement({
legacy: placement
}), [placement]);
// DropdownMenu has no `shouldFitViewport` equivalent, so neither axis ever
// fits and `blockSize` stays `'content'` - `MenuGroup`'s `maxHeight` owns the
// block axis.
useAnchoredPopover({
anchorRef: triggerRef,
popoverRef,
placement: topLayerPlacement,
isOpen: isLocalOpen,
inlineSize: shouldFitContainer ? 'min-anchor' : 'content'
});
// Close handling.
// Focus restoration is handled natively by the Popover API:
// - Escape: browser restores focus to the trigger automatically
// - Click-outside: browser does NOT restore (correct behavior)
//
// The only custom focus handling needed is `returnFocusRef`: when provided,
// we redirect focus to a different element than the trigger. We do this
// in the onClose callback via rAF, which runs after the browser's native
// restoration, effectively overriding it.
const handleOnClose = useCallback(() => {
if (returnFocusRef) {
requestAnimationFrame(() => {
var _returnFocusRef$curre;
(_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 ? void 0 : _returnFocusRef$curre.focus();
});
}
setLocalIsOpen(false);
onOpenChange({
isOpen: false,
event: null
});
}, [onOpenChange, returnFocusRef, setLocalIsOpen]);
// Trigger click handling.
const handleTriggerClicked = useCallback(event => {
const nextIsOpen = !isLocalOpen;
setLocalIsOpen(nextIsOpen);
// Extract the native DOM event for onOpenChange
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
onOpenChange({
isOpen: nextIsOpen,
event: nativeEvent
});
}, [isLocalOpen, setLocalIsOpen, onOpenChange]);
const {
isFocused,
bindFocus
} = useFocus();
// When trigger is focused, open dropdown on ArrowDown (top-level only).
// Per WAI-ARIA, ArrowDown opens a menu from a menubar/button trigger,
// but inside a vertical submenu, ArrowDown navigates between siblings
// and ArrowRight opens nested menus instead.
useEffect(() => {
var _triggerRef$current;
if (!isFocused || isLocalOpen) {
return;
}
// Do not open on ArrowDown if this trigger is inside a parent menu.
// Nested menus should only be opened via ArrowRight or Enter.
const isNestedTrigger = Boolean((_triggerRef$current = triggerRef.current) === null || _triggerRef$current === void 0 ? void 0 : _triggerRef$current.closest('[role="menu"]'));
return bind(window, {
type: 'keydown',
listener: function openOnKeyDown(e) {
if (e.key === KEY_DOWN && !isNestedTrigger) {
e.preventDefault();
handleTriggerClicked(e);
}
}
});
}, [isFocused, isLocalOpen, handleTriggerClicked]);
const handleNestedOpen = useCallback(({
trigger
}) => {
trigger.click();
}, []);
const handleTabClose = useCallback(() => {
const currentMenu = popoverRef.current;
if (!currentMenu) {
return;
}
// Hiding the root native popover synchronously closes its descendant popovers and restores
// focus to the root trigger. The browser can then perform the Tab default action from there.
getRootMenuPopover({
menu: currentMenu
}).hidePopover();
}, []);
useArrowNavigation({
containerRef: popoverRef,
onClose: handleTabClose,
onNestedOpen: handleNestedOpen,
onNestedClose: handleOnClose,
isEnabled: isLocalOpen,
filter: isAtCurrentMenuLevel
});
// Close on menu item click.
// Close when a regular menuitem is clicked, but not checkboxes/radios
// and not nested triggers (items with aria-haspopup).
const handleMenuClick = useCallback(e => {
var _e$target$closest, _e$target;
if (!(e.target instanceof Element)) {
return;
}
const menuItem = (_e$target$closest = (_e$target = e.target).closest) === null || _e$target$closest === void 0 ? void 0 : _e$target$closest.call(_e$target, '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]');
if (!menuItem) {
return;
}
const isCheckboxOrRadio = menuItem.getAttribute('role') === 'menuitemcheckbox' || menuItem.getAttribute('role') === 'menuitemradio';
// Do not close the menu when clicking a nested trigger (aria-haspopup).
// The nested dropdown will handle its own open/close.
const isNestedTrigger = menuItem.hasAttribute('aria-haspopup');
if (!isCheckboxOrRadio && !isNestedTrigger) {
setLocalIsOpen(false);
onOpenChange({
isOpen: false,
event: e.nativeEvent
});
}
}, [setLocalIsOpen, onOpenChange]);
const ariaAttributes = getAriaForTrigger({
// Intentionally fudging the type here to allow the fudged aria-haspopup cast below,
// without having to use `as unknown as ...` which would lose even more type safety.
role: 'menu',
isOpen: isLocalOpen,
popoverId
});
// FUDGE(top-layer-api): cast `aria-haspopup` to the narrow shape that adopter
// public types expect. `@atlaskit/top-layer` types `aria-haspopup` as the wider
// WAI-ARIA union, but the public `CustomTriggerProps` (extending `@atlaskit/popup`
// `TriggerProps`) is intentionally kept narrow (`boolean | 'dialog'`) because the
// top-layer API surface is not yet settled. The runtime value is unchanged; only
// the TypeScript-visible type is narrowed at this boundary.
const narrowAriaAttributes = ariaAttributes;
/**
* Custom trigger consumers historically receive a callback ref,
* and their code assumes it is one.
* Using this wrapper for compatibility...
*/
const setTriggerRef = useCallback(node => {
triggerRef.current = node;
}, []);
const renderTrigger = () => {
if (typeof trigger === 'function') {
return trigger({
...narrowAriaAttributes,
onFocus: bindFocus.onFocus,
onBlur: bindFocus.onBlur,
triggerRef: setTriggerRef,
isSelected: isLocalOpen,
onClick: handleTriggerClicked,
testId: testId && `${testId}--trigger`
});
}
return /*#__PURE__*/React.createElement(Button, _extends({
onFocus: bindFocus.onFocus,
onBlur: bindFocus.onBlur,
ref: setTriggerRef
}, narrowAriaAttributes, {
isSelected: isLocalOpen,
iconAfter: iconProps => /*#__PURE__*/React.createElement(ExpandIcon, _extends({}, iconProps, {
size: "small"
})),
onClick: handleTriggerClicked,
testId: testId && `${testId}--trigger`,
"aria-label": label,
interactionName: interactionName
}), trigger);
};
return /*#__PURE__*/React.createElement(SelectionStore, null, renderTrigger(), /*#__PURE__*/React.createElement(Popover, {
ref: popoverRef,
id: popoverId,
role: "menu",
label: (_ref = menuLabel !== null && menuLabel !== void 0 ? menuLabel : label) !== null && _ref !== void 0 ? _ref : typeof trigger === 'string' ? trigger : 'Menu',
isOpen: isLocalOpen,
onClose: handleOnClose,
shouldAnimate: true,
placement: topLayerPlacement,
testId: testId && `${testId}--content`
}, /*#__PURE__*/React.createElement(PopoverSurface, null, /*#__PURE__*/React.createElement(MenuGroup, {
maxHeight: MAX_HEIGHT,
maxWidth: shouldFitContainer ? undefined : 800,
onClick: handleMenuClick,
spacing: spacing,
testId: testId && `${testId}--menu-wrapper--menu-group`
}, isLoading ? /*#__PURE__*/React.createElement(LoadingIndicator, {
statusLabel: statusLabel,
testId: testId && `${testId}--menu-wrapper--loading-indicator`
}) : children))));
}
export default DropdownMenuTopLayer;