UNPKG

@atlaskit/avatar-group

Version:

An avatar group displays a number of avatars grouped together in a stack or grid.

312 lines (306 loc) 11.7 kB
import React, { useCallback, useEffect, useState } from 'react'; import { bind } from 'bind-event-listener'; import Avatar from '@atlaskit/avatar'; import { KEY_DOWN } from '@atlaskit/ds-lib/keycodes'; import noop from '@atlaskit/ds-lib/noop'; import useFocus from '@atlaskit/ds-lib/use-focus-event'; import { useId } from '@atlaskit/ds-lib/use-id'; import { Section } from '@atlaskit/menu'; import { Motion } from '@atlaskit/motion'; import { fg } from '@atlaskit/platform-feature-flags'; import Popup from '@atlaskit/popup'; import Tooltip from '@atlaskit/tooltip'; import AvatarGroupItem from './avatar-group-item'; import { MoreDropdownTopLayer } from './avatar-group-top-layer'; import { getOverrides } from './get-overrides'; import Grid from './grid'; import FocusManager from './internal/components/focus-manager'; import PopupAvatarGroup from './internal/components/popup-avatar-group'; import MoreIndicator from './more-indicator'; import Stack from './stack'; import { composeUniqueKey } from './utils'; const MAX_COUNT = { grid: 11, stack: 5 }; /** * __Avatar group__ * * An avatar group displays a number of avatars grouped together in a stack or grid. * * - [Examples](https://atlassian.design/components/avatar-group/examples) * - [Code](https://atlassian.design/components/avatar-group/code) * - [Usage](https://atlassian.design/components/avatar-group/usage) */ const AvatarGroup = ({ appearance = 'stack', moreIndicatorAppearance, avatar = Avatar, borderColor, boundariesElement, data, isTooltipDisabled, maxCount, onAvatarClick, onMoreClick, overrides, showMoreButtonProps = {}, size = 'medium', testId, label = 'avatar group', moreIndicatorLabel, tooltipPosition = 'bottom', shouldPopupRenderToParent = false }) => { const [isTriggeredUsingKeyboard, setTriggeredUsingKeyboard] = useState(false); const [isOpen, setIsOpen] = useState(false); const onClose = useCallback(() => setIsOpen(false), []); const handleTriggerClicked = useCallback(event => { const { clientX, clientY, type } = event; // Hitting enter/space is registered as a click with both clientX and clientY === 0 if (type === 'keydown' || clientX === 0 || clientY === 0) { setTriggeredUsingKeyboard(true); } setIsOpen(isOpen => !isOpen); }, []); const { isFocused, bindFocus } = useFocus(); const { AvatarGroupItem: avatarGroupItemOverrides, Avatar: avatarOverrides, MoreIndicator: moreIndicatorOverrides } = getOverrides(overrides); // When a trigger is focused, we want to open the popup // the user presses the DownArrow. // Skipped when top-layer is enabled — DropdownMenu/top-layer handles // ArrowDown-to-open and arrow key navigation internally. useEffect(() => { // Set initial value if popup is closed if (!isOpen) { setTriggeredUsingKeyboard(false); } // Top-layer path: ArrowDown-to-open is handled by the menu's // own focus management, so skip the parent's ArrowDown handler. if (fg('platform-dst-top-layer')) { return noop; } // Only need to listen for keydown when focused if (!isFocused) { return noop; } // Being safe: we don't want to open the popup if it is already open // Note: This shouldn't happen as the trigger should not be able to get focus if (isOpen) { return noop; } bind(window, { type: 'keydown', listener: function openOnKeyDown(e) { if (e.key === KEY_DOWN) { // prevent page scroll e.preventDefault(); handleTriggerClicked(e); } } }); const unbind = () => { bind(window, { type: 'keydown', listener: function openOnKeyDown(e) { if (e.key === KEY_DOWN) { // prevent page scroll e.preventDefault(); handleTriggerClicked(e); } } }); }; return unbind; }, [isFocused, isOpen, handleTriggerClicked]); function renderMoreDropdown(max, total, labelId) { if (total <= max) { return null; } const renderMoreButton = ({ 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-haspopup': ariaHasPopup, onClick, ...props }) => moreIndicatorOverrides.render(MoreIndicator, { buttonProps: showMoreButtonProps, borderColor: borderColor, count: total - max, size: size, testId: testId && `${testId}--overflow-menu--trigger`, isActive: isOpen, moreIndicatorLabel: moreIndicatorLabel, 'aria-controls': ariaControls, 'aria-expanded': ariaExpanded, 'aria-haspopup': ariaHasPopup, onClick, ...props, ...(fg('jira-ai-agent-stack') && { appearance: moreIndicatorAppearance }) }); // bail if the consumer wants to handle onClick if (typeof onMoreClick === 'function') { return renderMoreButton({ onClick: onMoreClick }); } if (fg('platform-dst-top-layer')) { return /*#__PURE__*/React.createElement(MoreDropdownTopLayer, { isOpen: isOpen, onClose: onClose, isTriggeredUsingKeyboard: isTriggeredUsingKeyboard, data: data, max: max // eslint-disable-next-line @repo/internal/react/no-unsafe-overrides , overrides: overrides, onAvatarClick: onAvatarClick, testId: testId, labelId: labelId, renderMoreButton: renderMoreButton, handleTriggerClicked: handleTriggerClicked, bindFocus: bindFocus }); } // split boundariesElement into `boundary` and `rootBoundary` props for Popup const boundary = boundariesElement === 'scrollParent' ? 'clippingParents' : undefined; const rootBoundary = (() => { if (boundariesElement === 'scrollParent') { return undefined; } return boundariesElement === 'window' ? 'document' : 'viewport'; })(); const avatarComponent = avatar && fg('platform-avatar-group-pass-avatar-to-item') ? avatar : undefined; return /*#__PURE__*/React.createElement(Popup, { isOpen: isOpen, onClose: onClose, placement: "bottom-end", boundary: boundary, rootBoundary: rootBoundary, shouldFlip: true, zIndex: 510, shouldRenderToParent: shouldPopupRenderToParent, content: ({ setInitialFocusRef }) => /*#__PURE__*/React.createElement(FocusManager, null, /*#__PURE__*/React.createElement(PopupAvatarGroup, { onClick: e => e.stopPropagation(), minWidth: 250, maxHeight: 300, setInitialFocusRef: isTriggeredUsingKeyboard ? setInitialFocusRef : undefined }, /*#__PURE__*/React.createElement(Section, { titleId: labelId, testId: `${testId}--section` }, data.slice(max).map((avatarData, index) => avatarGroupItemOverrides.render(AvatarGroupItem, { avatar: avatarData, avatarComponent, onAvatarClick, avatarOverrides, testId: testId && `${testId}--avatar-group-item-${index + max}`, index: index + max }, // This index holds the true index, // adding up the index of non-overflowed avatars and overflowed avatars. index + max))))), trigger: triggerProps => fg('platform-dst-motion-uplift') ? /*#__PURE__*/React.createElement(Motion, { enteringAnimation: "var(--ds-avatar-enter, 150ms cubic-bezier(0.4, 1, 0.6, 1) ScaleIn80to100, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)", exitingAnimation: "var(--ds-avatar-exit, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) ScaleOut100to80, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)" }, renderMoreButton({ ...triggerProps, ...bindFocus, onClick: handleTriggerClicked })) : renderMoreButton({ ...triggerProps, ...bindFocus, onClick: handleTriggerClicked }), testId: testId && `${testId}--overflow-menu` }); } const max = maxCount === undefined || maxCount === 0 ? MAX_COUNT[appearance] : maxCount; const total = data.length; const maxAvatar = total > max ? max - 1 : max; const groupId = useId(); return appearance === 'stack' ? /*#__PURE__*/React.createElement(Stack, { id: groupId, testId: testId && `${testId}--avatar-group`, "aria-label": label, size: size }, data.slice(0, maxAvatar).map((avatarData, idx) => { const callback = avatarData.onClick || onAvatarClick; const finalAvatar = avatarOverrides.render(avatar, { ...avatarData, size, borderColor: borderColor || avatarData.borderColor, testId: testId && `${testId}--avatar-${idx}`, onClick: callback ? (event, analyticsEvent) => { callback(event, analyticsEvent, idx); } : undefined, stackIndex: max - idx }, idx); if (fg('platform-dst-motion-uplift')) { return /*#__PURE__*/React.createElement(Motion, { enteringAnimation: "var(--ds-avatar-enter, 150ms cubic-bezier(0.4, 1, 0.6, 1) ScaleIn80to100, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)", exitingAnimation: "var(--ds-avatar-exit, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) ScaleOut100to80, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)", key: composeUniqueKey(avatarData, idx) }, !isTooltipDisabled && !avatarData.isDisabled ? /*#__PURE__*/React.createElement(Tooltip, { content: avatarData.name, testId: testId && `${testId}--tooltip-${idx}`, position: tooltipPosition }, finalAvatar) : finalAvatar); } else { return !isTooltipDisabled && !avatarData.isDisabled ? /*#__PURE__*/React.createElement(Tooltip, { key: composeUniqueKey(avatarData, idx), content: avatarData.name, testId: testId && `${testId}--tooltip-${idx}`, position: tooltipPosition }, finalAvatar) : finalAvatar; } }), renderMoreDropdown(+maxAvatar, total, groupId)) : /*#__PURE__*/React.createElement(Grid, { id: groupId, testId: testId && `${testId}--avatar-group`, "aria-label": label }, data.slice(0, maxAvatar).map((avatarData, idx) => { const callback = avatarData.onClick || onAvatarClick; const finalAvatar = avatarOverrides.render(avatar, { ...avatarData, size, borderColor: borderColor || avatarData.borderColor, testId: testId && `${testId}--avatar-${idx}`, onClick: callback ? (event, analyticsEvent) => { callback(event, analyticsEvent, idx); } : undefined, stackIndex: max - idx }, idx); if (fg('platform-dst-motion-uplift')) { return /*#__PURE__*/React.createElement(Motion, { enteringAnimation: "var(--ds-avatar-enter, 150ms cubic-bezier(0.4, 1, 0.6, 1) ScaleIn80to100, 150ms cubic-bezier(0.4, 1, 0.6, 1) FadeIn0to100)", exitingAnimation: "var(--ds-avatar-exit, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) ScaleOut100to80, 100ms cubic-bezier(0.6, 0, 0.8, 0.6) FadeOut100to0)", key: composeUniqueKey(avatarData, idx) }, !isTooltipDisabled && !avatarData.isDisabled ? /*#__PURE__*/React.createElement(Tooltip, { content: avatarData.name, testId: testId && `${testId}--tooltip-${idx}`, position: tooltipPosition }, finalAvatar) : finalAvatar); } else { return !isTooltipDisabled && !avatarData.isDisabled ? /*#__PURE__*/React.createElement(Tooltip, { key: composeUniqueKey(avatarData, idx), content: avatarData.name, testId: testId && `${testId}--tooltip-${idx}`, position: tooltipPosition }, finalAvatar) : finalAvatar; } }), renderMoreDropdown(+maxAvatar, total, groupId)); }; export default AvatarGroup;