@atlaskit/avatar-group
Version:
An avatar group displays a number of avatars grouped together in a stack or grid.
172 lines (170 loc) • 7.53 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
import _defineProperty from "@babel/runtime/helpers/defineProperty";
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, useRef } from 'react';
import { bind } from 'bind-event-listener';
import { KEY_DOWN } from '@atlaskit/ds-lib/keycodes';
import useFocus from '@atlaskit/ds-lib/use-focus-event';
import { MenuGroup, Section } from '@atlaskit/menu';
import { slideAndFade } from '@atlaskit/top-layer/animations';
import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map';
import { Popup } from '@atlaskit/top-layer/popup';
import { useArrowNavigation } from '@atlaskit/top-layer/use-arrow-navigation';
import AvatarGroupItem from './avatar-group-item';
var animation = slideAndFade();
var topLayerPlacement = fromLegacyPlacement({
legacy: 'bottom-end'
});
function getOverrides(overrides) {
return {
AvatarGroupItem: _objectSpread({
render: function render(Component, props, index) {
return /*#__PURE__*/React.createElement(Component, _extends({
key: index
}, props));
}
}, overrides === null || overrides === void 0 ? void 0 : overrides.AvatarGroupItem),
Avatar: _objectSpread({
render: function render(Component, props, index) {
return /*#__PURE__*/React.createElement(Component, _extends({
key: index
}, props));
}
}, overrides === null || overrides === void 0 ? void 0 : overrides.Avatar),
MoreIndicator: _objectSpread({
render: function render(Component, props) {
return /*#__PURE__*/React.createElement(Component, props);
}
}, overrides === null || overrides === void 0 ? void 0 : overrides.MoreIndicator)
};
}
/**
* Top-layer implementation of the avatar group overflow dropdown.
*
* Replaces the legacy `@atlaskit/popup` rendering pipeline
* (Popper.js + Portal + focus-trap + @atlaskit/layering)
* with the native Popover API + CSS Anchor Positioning via `@atlaskit/top-layer`.
*
* Uses `role="menu"` with arrow key navigation for correct menu semantics.
*
* Gated behind the `platform-dst-top-layer` feature flag.
*
* Legacy props that are no-ops in the top-layer path (not accepted here):
* - zIndex: stacking managed by browser top layer
* - shouldRenderToParent: always renders in top layer
* - boundary / rootBoundary: viewport is the natural boundary
* - shouldFlip: CSS Anchor Positioning handles flipping
*/
export function MoreDropdownTopLayer(_ref) {
var isOpen = _ref.isOpen,
onClose = _ref.onClose,
_isTriggeredUsingKeyboard = _ref.isTriggeredUsingKeyboard,
data = _ref.data,
max = _ref.max,
overrides = _ref.overrides,
onAvatarClick = _ref.onAvatarClick,
testId = _ref.testId,
labelId = _ref.labelId,
renderMoreButton = _ref.renderMoreButton,
handleTriggerClicked = _ref.handleTriggerClicked,
_bindFocus = _ref.bindFocus;
var handleOnClose = useCallback(function (_ref2) {
var _reason = _ref2.reason;
onClose();
}, [onClose]);
var menuRef = useRef(null);
var overflowMenuTestId = testId ? "".concat(testId, "--overflow-menu") : undefined;
// Arrow key navigation inside the open menu
useArrowNavigation({
containerRef: menuRef,
onClose: onClose,
isEnabled: isOpen
});
// ArrowDown-to-open: when the trigger is focused and the menu is closed,
// pressing ArrowDown opens the menu (WAI-ARIA menu button pattern).
// We track focus on the trigger wrapper to avoid threading onFocus/onBlur
// through the renderMoreButton/MoreIndicator prop plumbing.
var triggerWrapperRef = useRef(null);
var _useFocus = useFocus(),
isFocused = _useFocus.isFocused,
triggerFocusBind = _useFocus.bindFocus;
useEffect(function () {
if (!isFocused || isOpen) {
return;
}
return bind(window, {
type: 'keydown',
listener: function openOnArrowDown(e) {
if (e.key === KEY_DOWN) {
// Prevent page scroll when opening the menu via ArrowDown.
e.preventDefault();
handleTriggerClicked(e);
}
}
});
}, [isFocused, isOpen, handleTriggerClicked]);
return /*#__PURE__*/React.createElement(Popup, {
placement: topLayerPlacement,
onClose: handleOnClose,
testId: overflowMenuTestId
}, /*#__PURE__*/React.createElement(Popup.TriggerFunction, null, function (_ref3) {
var ref = _ref3.ref,
ariaAttributes = _ref3.ariaAttributes;
return (
/*#__PURE__*/
// Workaround: wrapping span to track trigger focus for ArrowDown-to-open.
//
// The `useFocus` hook needs onFocus/onBlur on the trigger element,
// but we cannot thread those through the renderMoreButton → MoreIndicator
// prop plumbing without changing those APIs (onFocus/onBlur would need
// to go into MoreIndicator's `buttonProps`, but renderMoreButton does not
// expose that).
//
// Using `display: contents` so the span does not affect layout — the
// button renders as if the span were not there. Focus events from the
// button bubble up to this span, allowing useFocus to track state.
//
// If MoreIndicator's API is refactored to accept focus handlers via
// buttonProps, this wrapper can be removed.
React.createElement("span", {
ref: triggerWrapperRef,
onFocus: triggerFocusBind.onFocus,
onBlur: triggerFocusBind.onBlur
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop -- display: contents is a layout-neutral wrapper; it cannot affect visual output.
,
style: {
display: 'contents'
}
}, renderMoreButton({
ref: ref,
'aria-controls': isOpen ? ariaAttributes['aria-controls'] : undefined,
'aria-expanded': isOpen,
'aria-haspopup': true,
onClick: handleTriggerClicked
}))
);
}), /*#__PURE__*/React.createElement(Popup.Content, {
role: "menu",
label: "avatar group",
isOpen: isOpen,
animate: animation,
testId: overflowMenuTestId ? "".concat(overflowMenuTestId, "--content") : undefined
}, /*#__PURE__*/React.createElement(Popup.Surface, null, /*#__PURE__*/React.createElement("div", {
ref: menuRef
}, /*#__PURE__*/React.createElement(MenuGroup, {
minWidth: 250,
maxHeight: 300
}, /*#__PURE__*/React.createElement(Section, {
titleId: labelId,
testId: testId ? "".concat(testId, "--section") : undefined
}, data.slice(max).map(function (avatar, index) {
return getOverrides(overrides).AvatarGroupItem.render(AvatarGroupItem, {
avatar: avatar,
onAvatarClick: onAvatarClick,
testId: testId ? "".concat(testId, "--avatar-group-item-").concat(index + max) : undefined,
index: index + max,
role: 'menuitem'
}, index + max);
})))))));
}