@atlaskit/dropdown-menu
Version:
A dropdown menu displays a list of actions or options to a user.
362 lines (348 loc) • 16.3 kB
JavaScript
/* dropdown-menu-top-layer.tsx generated by @compiled/babel-plugin v2.0.0 */
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
var _typeof = require("@babel/runtime/helpers/typeof");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
require("./dropdown-menu-top-layer.compiled.css");
var _react = _interopRequireWildcard(require("react"));
var React = _react;
var _runtime = require("@compiled/react/runtime");
var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends"));
var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
var _slicedToArray2 = _interopRequireDefault(require("@babel/runtime/helpers/slicedToArray"));
var _bindEventListener = require("bind-event-listener");
var _new = _interopRequireDefault(require("@atlaskit/button/new"));
var _keycodes = require("@atlaskit/ds-lib/keycodes");
var _mergeRefs = _interopRequireDefault(require("@atlaskit/ds-lib/merge-refs"));
var _noop = _interopRequireDefault(require("@atlaskit/ds-lib/noop"));
var _useControlled = _interopRequireDefault(require("@atlaskit/ds-lib/use-controlled"));
var _useFocusEvent = _interopRequireDefault(require("@atlaskit/ds-lib/use-focus-event"));
var _chevronDown = _interopRequireDefault(require("@atlaskit/icon/core/chevron-down"));
var _menuGroup = _interopRequireDefault(require("@atlaskit/menu/menu-group"));
var _spinner = _interopRequireDefault(require("@atlaskit/spinner"));
var _focus = require("@atlaskit/top-layer/focus");
var _getAriaForTrigger = require("@atlaskit/top-layer/get-aria-for-trigger");
var _placementMap = require("@atlaskit/top-layer/placement-map");
var _popover = require("@atlaskit/top-layer/popover");
var _useAnchorPosition = require("@atlaskit/top-layer/use-anchor-position");
var _useArrowNavigation = require("@atlaskit/top-layer/use-arrow-navigation");
var _usePopoverId = require("@atlaskit/top-layer/use-popover-id");
var _useWidthFromAnchor = require("@atlaskit/top-layer/use-width-from-anchor");
var _selectionStore = _interopRequireDefault(require("./internal/context/selection-store"));
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != _typeof(e) && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t in e) "default" !== _t && {}.hasOwnProperty.call(e, _t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t)) && (i.get || i.set) ? o(f, _t, i) : f[_t] = e[_t]); return f; })(e, t); }
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) { (0, _defineProperty2.default)(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; }
var MAX_HEIGHT = "calc(100vh - 16px)";
var styles = {
spinnerContainer: "_1e0c1txw _1ul91lit _1bah1h6o _1q51v47k _y4tiv47k _85i5v47k _bozgv47k",
menuContent: "_2rkofajl _bfhk1bhr _16qs130s"
};
/**
* 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
*/
/**
* Determines whether a trigger interaction was keyboard-initiated.
*
* Keyboard signals:
* - `type === 'keydown'` (native KeyboardEvent from ArrowDown handler)
* - `clientX/clientY === 0` (assistive technology click)
* - `detail === 0` (keyboard-activated click via Enter/Space)
*/
function isKeyboardTriggered(event) {
if (event.type === 'keydown') {
return true;
}
if ('clientX' in event && (event.clientX === 0 || event.clientY === 0)) {
return true;
}
if (event.detail === 0) {
return true;
}
return false;
}
/**
* Loading indicator for the dropdown menu.
*/
function LoadingIndicator(_ref) {
var _ref$statusLabel = _ref.statusLabel,
statusLabel = _ref$statusLabel === void 0 ? 'Loading' : _ref$statusLabel,
testId = _ref.testId;
return /*#__PURE__*/React.createElement("div", {
role: "menuitem",
className: (0, _runtime.ax)([styles.spinnerContainer])
}, /*#__PURE__*/React.createElement(_spinner.default, {
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(_ref2) {
var _ref5;
var _ref2$autoFocus = _ref2.autoFocus,
autoFocus = _ref2$autoFocus === void 0 ? false : _ref2$autoFocus,
children = _ref2.children,
_ref2$defaultOpen = _ref2.defaultOpen,
defaultOpen = _ref2$defaultOpen === void 0 ? false : _ref2$defaultOpen,
_ref2$isLoading = _ref2.isLoading,
isLoading = _ref2$isLoading === void 0 ? false : _ref2$isLoading,
isOpenProp = _ref2.isOpen,
_ref2$onOpenChange = _ref2.onOpenChange,
onOpenChange = _ref2$onOpenChange === void 0 ? _noop.default : _ref2$onOpenChange,
_ref2$placement = _ref2.placement,
placement = _ref2$placement === void 0 ? 'bottom-start' : _ref2$placement,
_ref2$shouldFitContai = _ref2.shouldFitContainer,
shouldFitContainer = _ref2$shouldFitContai === void 0 ? false : _ref2$shouldFitContai,
returnFocusRef = _ref2.returnFocusRef,
spacing = _ref2.spacing,
statusLabel = _ref2.statusLabel,
testId = _ref2.testId,
trigger = _ref2.trigger,
label = _ref2.label,
interactionName = _ref2.interactionName,
menuLabel = _ref2.menuLabel;
var _useControlledState = (0, _useControlled.default)(isOpenProp, function () {
return defaultOpen;
}),
_useControlledState2 = (0, _slicedToArray2.default)(_useControlledState, 2),
isLocalOpen = _useControlledState2[0],
setLocalIsOpen = _useControlledState2[1];
var triggerRef = (0, _react.useRef)(null);
var popoverRef = (0, _react.useRef)(null);
var menuRef = (0, _react.useRef)(null);
var _useState = (0, _react.useState)(false),
_useState2 = (0, _slicedToArray2.default)(_useState, 2),
isTriggeredUsingKeyboard = _useState2[0],
setTriggeredUsingKeyboard = _useState2[1];
var popoverId = (0, _usePopoverId.usePopoverId)();
var topLayerPlacement = (0, _react.useMemo)(function () {
return (0, _placementMap.fromLegacyPlacement)({
legacy: placement
});
}, [placement]);
(0, _useAnchorPosition.useAnchorPosition)({
anchorRef: triggerRef,
popoverRef: popoverRef,
placement: topLayerPlacement,
isOpen: isLocalOpen
});
(0, _useWidthFromAnchor.useWidthFromAnchor)({
mode: shouldFitContainer ? 'min-anchor' : 'none',
popoverRef: popoverRef,
anchorRef: triggerRef,
isOpen: isLocalOpen
});
// 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.
var handleOnClose = (0, _react.useCallback)(function (_ref3) {
var _reason = _ref3.reason;
if (returnFocusRef) {
requestAnimationFrame(function () {
var _returnFocusRef$curre;
(_returnFocusRef$curre = returnFocusRef.current) === null || _returnFocusRef$curre === void 0 || _returnFocusRef$curre.focus();
});
}
setLocalIsOpen(false);
onOpenChange({
isOpen: false,
event: null
});
}, [onOpenChange, returnFocusRef, setLocalIsOpen]);
// Trigger click handling.
var handleTriggerClicked = (0, _react.useCallback)(function (event) {
var newValue = !isLocalOpen;
setTriggeredUsingKeyboard(isKeyboardTriggered(event));
setLocalIsOpen(newValue);
// Extract the native DOM event for onOpenChange
var nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
onOpenChange({
isOpen: newValue,
event: nativeEvent
});
}, [isLocalOpen, setLocalIsOpen, onOpenChange]);
var _useFocus = (0, _useFocusEvent.default)(),
isFocused = _useFocus.isFocused,
bindFocus = _useFocus.bindFocus;
// 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.
(0, _react.useEffect)(function () {
var _triggerRef$current;
if (!isFocused || isLocalOpen) {
return _noop.default;
}
// Do not open on ArrowDown if this trigger is inside a parent menu.
// Nested menus should only be opened via ArrowRight or Enter.
var isNestedTrigger = ((_triggerRef$current = triggerRef.current) === null || _triggerRef$current === void 0 ? void 0 : _triggerRef$current.closest('[role="menu"]')) != null;
return (0, _bindEventListener.bind)(window, {
type: 'keydown',
listener: function openOnKeyDown(e) {
if (e.key === _keycodes.KEY_DOWN && !isNestedTrigger) {
e.preventDefault();
handleTriggerClicked(e);
} else if ((e.code === _keycodes.KEY_SPACE || e.key === _keycodes.KEY_ENTER) && e.detail === 0) {
setTriggeredUsingKeyboard(true);
}
}
});
}, [isFocused, isLocalOpen, handleTriggerClicked]);
// Arrow navigation.
// useArrowNavigation handles ArrowUp/Down, Home/End, and Tab-to-close
// by querying focusable elements in the menu DOM container.
var handleArrowClose = (0, _react.useCallback)(function () {
handleOnClose({
reason: 'escape'
});
}, [handleOnClose]);
var handleNestedOpen = (0, _react.useCallback)(function (_ref4) {
var trigger = _ref4.trigger;
trigger.click();
}, []);
var handleNestedClose = (0, _react.useCallback)(function () {
handleOnClose({
reason: 'escape'
});
}, [handleOnClose]);
(0, _useArrowNavigation.useArrowNavigation)({
containerRef: menuRef,
onClose: handleArrowClose,
onNestedOpen: handleNestedOpen,
onNestedClose: handleNestedClose,
isEnabled: isLocalOpen,
filter: _useArrowNavigation.isAtCurrentMenuLevel
});
// Auto-focus first item on open.
(0, _react.useEffect)(function () {
if (!isLocalOpen || !isTriggeredUsingKeyboard && !autoFocus) {
return;
}
requestAnimationFrame(function () {
var menu = menuRef.current;
if (!menu) {
return;
}
var firstItem = (0, _focus.getFirstFocusable)({
container: menu
});
firstItem === null || firstItem === void 0 || firstItem.focus();
});
}, [isLocalOpen, isTriggeredUsingKeyboard, autoFocus]);
// Close on menu item click.
// Close when a regular menuitem is clicked, but not checkboxes/radios
// and not nested triggers (items with aria-haspopup).
var handleMenuClick = (0, _react.useCallback)(function (e) {
var _target$closest;
var target = e.target;
var menuItem = (_target$closest = target.closest) === null || _target$closest === void 0 ? void 0 : _target$closest.call(target, '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]');
if (!menuItem) {
return;
}
var 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.
var isNestedTrigger = menuItem.hasAttribute('aria-haspopup');
if (!isCheckboxOrRadio && !isNestedTrigger) {
setLocalIsOpen(false);
onOpenChange({
isOpen: false,
event: e.nativeEvent
});
}
}, [setLocalIsOpen, onOpenChange]);
var ariaAttributes = (0, _getAriaForTrigger.getAriaForTrigger)({
role: 'menu',
isOpen: isLocalOpen,
popoverId: 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.
var narrowAriaAttributes = ariaAttributes;
var renderTrigger = function renderTrigger() {
var setRef = function setRef(node) {
triggerRef.current = node;
};
var combinedRef = (0, _mergeRefs.default)([setRef]);
if (typeof trigger === 'function') {
return trigger(_objectSpread(_objectSpread(_objectSpread({}, narrowAriaAttributes), bindFocus), {}, {
triggerRef: combinedRef,
isSelected: isLocalOpen,
onClick: handleTriggerClicked,
testId: testId && "".concat(testId, "--trigger")
}));
}
return /*#__PURE__*/React.createElement(_new.default, (0, _extends2.default)({}, bindFocus, {
ref: combinedRef
}, narrowAriaAttributes, {
isSelected: isLocalOpen,
iconAfter: function iconAfter(iconProps) {
return /*#__PURE__*/React.createElement(_chevronDown.default, (0, _extends2.default)({}, iconProps, {
size: "small"
}));
},
onClick: handleTriggerClicked,
testId: testId && "".concat(testId, "--trigger"),
"aria-label": label,
interactionName: interactionName
}), trigger);
};
return /*#__PURE__*/React.createElement(_selectionStore.default, null, renderTrigger(), /*#__PURE__*/React.createElement(_popover.Popover, {
ref: popoverRef,
id: popoverId,
role: "menu",
label: (_ref5 = menuLabel !== null && menuLabel !== void 0 ? menuLabel : label) !== null && _ref5 !== void 0 ? _ref5 : typeof trigger === 'string' ? trigger : 'Menu',
isOpen: isLocalOpen,
onClose: handleOnClose,
shouldAnimate: true,
placement: topLayerPlacement,
testId: testId && "".concat(testId, "--content")
}, /*#__PURE__*/React.createElement("div", {
ref: menuRef,
className: (0, _runtime.ax)([styles.menuContent])
}, /*#__PURE__*/React.createElement(_menuGroup.default, {
isLoading: isLoading,
maxHeight: MAX_HEIGHT,
maxWidth: shouldFitContainer ? undefined : 800,
onClick: handleMenuClick,
role: "menu",
spacing: spacing,
testId: testId && "".concat(testId, "--menu-wrapper--menu-group"),
menuLabel: menuLabel
}, isLoading ? /*#__PURE__*/React.createElement(LoadingIndicator, {
statusLabel: statusLabel,
testId: testId && "".concat(testId, "--menu-wrapper--loading-indicator")
}) : children))));
}
var _default = exports.default = DropdownMenuTopLayer;