@atlaskit/avatar-group
Version:
An avatar group displays a number of avatars grouped together in a stack or grid.
165 lines (163 loc) • 6.06 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
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';
const animation = slideAndFade();
const topLayerPlacement = fromLegacyPlacement({
legacy: 'bottom-end'
});
function getOverrides(overrides) {
return {
AvatarGroupItem: {
render: (Component, props, index) => /*#__PURE__*/React.createElement(Component, _extends({
key: index
}, props)),
...(overrides === null || overrides === void 0 ? void 0 : overrides.AvatarGroupItem)
},
Avatar: {
render: (Component, props, index) => /*#__PURE__*/React.createElement(Component, _extends({
key: index
}, props)),
...(overrides === null || overrides === void 0 ? void 0 : overrides.Avatar)
},
MoreIndicator: {
render: (Component, props) => /*#__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({
isOpen,
onClose,
isTriggeredUsingKeyboard: _isTriggeredUsingKeyboard,
data,
max,
overrides,
onAvatarClick,
testId,
labelId,
renderMoreButton,
handleTriggerClicked,
bindFocus: _bindFocus
}) {
const handleOnClose = useCallback(({
reason: _reason
}) => {
onClose();
}, [onClose]);
const menuRef = useRef(null);
const overflowMenuTestId = testId ? `${testId}--overflow-menu` : undefined;
// Arrow key navigation inside the open menu
useArrowNavigation({
containerRef: menuRef,
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.
const triggerWrapperRef = useRef(null);
const {
isFocused,
bindFocus: triggerFocusBind
} = useFocus();
useEffect(() => {
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, ({
ref,
ariaAttributes
}) =>
/*#__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,
'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 ? `${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 ? `${testId}--section` : undefined
}, data.slice(max).map((avatar, index) => getOverrides(overrides).AvatarGroupItem.render(AvatarGroupItem, {
avatar,
onAvatarClick,
testId: testId ? `${testId}--avatar-group-item-${index + max}` : undefined,
index: index + max,
role: 'menuitem'
}, index + max))))))));
}