diff --git a/change/@fluentui-react-avatar-32be9545-3830-48b3-bca7-aa6f556ad333.json b/change/@fluentui-react-avatar-32be9545-3830-48b3-bca7-aa6f556ad333.json new file mode 100644 index 00000000000000..4ebe7eb4c604db --- /dev/null +++ b/change/@fluentui-react-avatar-32be9545-3830-48b3-bca7-aa6f556ad333.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Adding initial implementation of AvatarGroup", + "packageName": "@fluentui/react-avatar", + "email": "esteban.230@hotmail.com", + "dependentChangeType": "none" +} diff --git a/packages/react-components/react-avatar/etc/react-avatar.api.md b/packages/react-components/react-avatar/etc/react-avatar.api.md index 8c49b7d03b5355..ac1d410cb6a657 100644 --- a/packages/react-components/react-avatar/etc/react-avatar.api.md +++ b/packages/react-components/react-avatar/etc/react-avatar.api.md @@ -4,7 +4,6 @@ ```ts -import { Button } from '@fluentui/react-button'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; @@ -52,20 +51,21 @@ export type AvatarGroupItemState = ComponentState & { export type AvatarGroupProps = ComponentProps & { layout?: 'spread' | 'stack' | 'pie'; maxAvatars?: number; - overflowIndicator?: 'number-overflowed' | 'icon'; + overflowIndicator?: 'count' | 'icon'; size?: AvatarSizes; - strings?: AvatarGroupStrings; }; // @public (undocumented) export type AvatarGroupSlots = { - root: Slot<'div'>; - popoverTrigger?: Slot; - popoverSurface?: Slot; + root: NonNullable>; + overflowButton?: NonNullable>; + overflowList?: NonNullable>; + overflowSurface?: NonNullable>; }; // @public -export type AvatarGroupState = ComponentState & Required> & { +export type AvatarGroupState = ComponentState & Required> & { + hasOverflow: boolean; tooltipContent: TooltipProps['content']; }; diff --git a/packages/react-components/react-avatar/package.json b/packages/react-components/react-avatar/package.json index e32a6c18d63ff8..4d80df37e74508 100644 --- a/packages/react-components/react-avatar/package.json +++ b/packages/react-components/react-avatar/package.json @@ -34,7 +34,6 @@ }, "dependencies": { "@fluentui/react-badge": "9.0.0-rc.12", - "@fluentui/react-button": "9.0.0-rc.13", "@fluentui/react-context-selector": "9.0.0-rc.10", "@fluentui/react-icons": "^2.0.166-rc.3", "@fluentui/react-popover": "9.0.0-rc.13", diff --git a/packages/react-components/react-avatar/src/components/Avatar/useAvatarStyles.ts b/packages/react-components/react-avatar/src/components/Avatar/useAvatarStyles.ts index 0aaa8e4eb5b754..9b3072ba7e4fb9 100644 --- a/packages/react-components/react-avatar/src/components/Avatar/useAvatarStyles.ts +++ b/packages/react-components/react-avatar/src/components/Avatar/useAvatarStyles.ts @@ -212,7 +212,7 @@ const useStyles = makeStyles({ icon48: { fontSize: '48px' }, }); -const useSizeStyles = makeStyles({ +export const useSizeStyles = makeStyles({ 16: { width: '16px', height: '16px' }, 20: { width: '20px', height: '20px' }, 24: { width: '24px', height: '24px' }, diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.strings.tsx b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.strings.tsx deleted file mode 100644 index 0fde491356e2f3..00000000000000 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.strings.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import type { AvatarGroupStrings } from './AvatarGroup.types'; - -export const avatarGroupDefaultStrings: AvatarGroupStrings = { - tooltipContent: '{numOverflowedAvatars} more people.', -}; diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.test.tsx b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.test.tsx index d7dcc11af5dd35..50ff191fa4ac91 100644 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.test.tsx +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.test.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { render } from '@testing-library/react'; import { AvatarGroup } from './AvatarGroup'; +import { AvatarGroupItem } from '../AvatarGroupItem'; import { isConformant } from '../../common/isConformant'; +import { render } from '@testing-library/react'; describe('AvatarGroup', () => { // TODO: Remove component-has-static-classnames-object from disabled tests. @@ -18,7 +19,19 @@ describe('AvatarGroup', () => { // TODO add more tests here, and create visual regression tests in /apps/vr-tests it('renders a default state', () => { - const result = render(Default AvatarGroup); + const result = render( + + + + + + + + + + + , + ); expect(result.container).toMatchSnapshot(); }); }); diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.tsx b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.tsx index 4072bb6d4624e1..bedffe9ed21f69 100644 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.tsx +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.tsx @@ -6,7 +6,8 @@ import type { AvatarGroupProps } from './AvatarGroup.types'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; /** - * AvatarGroup component - TODO: add more docs + * The AvatarGroup component represents a group of multiple people or entities by taking care of the arrangement + * of individual Avatars in a spread, stack, or pie layout. */ export const AvatarGroup: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useAvatarGroup_unstable(props, ref); diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.types.ts b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.types.ts index b774264753ddf9..1b4e96f18a1384 100644 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.types.ts +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/AvatarGroup.types.ts @@ -1,21 +1,25 @@ -import { Button } from '@fluentui/react-button'; import { PopoverSurface } from '@fluentui/react-popover'; import { TooltipProps } from '@fluentui/react-tooltip'; import type { AvatarSizes } from '../Avatar/Avatar.types'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; export type AvatarGroupSlots = { - root: Slot<'div'>; + root: NonNullable>; /** * Popover trigger slot that can be used to change the overflow indicator. */ - popoverTrigger?: Slot; + overflowButton?: NonNullable>; + + /** + * Unordered list that contains the overflow AvatarGroupItems. + */ + overflowList?: NonNullable>; /** * Popover surface that will be displayed when the popover is triggered. */ - popoverSurface?: Slot; + overflowSurface?: NonNullable>; }; /** @@ -30,42 +34,38 @@ export type AvatarGroupProps = ComponentProps & { /** * Maximum number of Avatars to be displayed before overflowing. - * NOTE: if pie layout is used, `maxAvatars` will be ignored. + * Note: if pie layout is used, `maxAvatars` will be ignored. * @default 5 */ maxAvatars?: number; /** * Whether the overflow indicator should render an icon instead of the number of overflowed avatars. - * @default false + * Note: The overflowIndicator will default to `icon` when the size is less than 24. + * @default count */ - overflowIndicator?: 'number-overflowed' | 'icon'; + overflowIndicator?: 'count' | 'icon'; /** * Size of the avatars. * @default 32 */ size?: AvatarSizes; - - /** - * Strings for localizing text in the tooltip. - */ - strings?: AvatarGroupStrings; }; /** * State used in rendering AvatarGroup */ export type AvatarGroupState = ComponentState & - Required> & { + Required> & { + /** + * Whether there are more Avatars than `maxAvatars`. + * @default false + */ + hasOverflow: boolean; + + /** + * Tooltip content for the overflow indicator. + */ tooltipContent: TooltipProps['content']; }; - -// TODO: Remove strings from AvatarGroup. -export type AvatarGroupStrings = { - /** - * Text applied to the overflow indicator's tooltip. - * Can include the token "\{numOverflowedAvatars\}" which will be replaced with the number of overflowed avatars. - */ - tooltipContent: string; -}; diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap b/packages/react-components/react-avatar/src/components/AvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap index 3bba8472700fa2..bfa93f39543024 100644 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap @@ -4,8 +4,84 @@ exports[`AvatarGroup renders a default state 1`] = `
- Default AvatarGroup +
+ + + AM + + +
+
+ + + DP + + +
+
+ + + RT + + +
+
+ + + KS + + +
+
`; diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/renderAvatarGroup.tsx b/packages/react-components/react-avatar/src/components/AvatarGroup/renderAvatarGroup.tsx index 8db33bc16c2e5d..c67a04f214b6ce 100644 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/renderAvatarGroup.tsx +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/renderAvatarGroup.tsx @@ -1,5 +1,8 @@ import * as React from 'react'; +import { Popover, PopoverTrigger } from '@fluentui/react-popover'; +import { Tooltip } from '@fluentui/react-tooltip'; import { getSlots } from '@fluentui/react-utilities'; +import { AvatarGroupContext } from '../../contexts/AvatarGroupContext'; import type { AvatarGroupState, AvatarGroupSlots } from './AvatarGroup.types'; /** @@ -7,7 +10,27 @@ import type { AvatarGroupState, AvatarGroupSlots } from './AvatarGroup.types'; */ export const renderAvatarGroup_unstable = (state: AvatarGroupState) => { const { slots, slotProps } = getSlots(state); + const { layout, size } = state; - // TODO Add additional slots in the appropriate place - return ; + return ( + + + {state.root.children} + {state.hasOverflow && slots.overflowButton && slots.overflowSurface && slots.overflowList && ( + + + + + + + + + + + + + )} + + + ); }; diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroup.ts b/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroup.ts deleted file mode 100644 index 8b3d74161ad6d6..00000000000000 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroup.ts +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from 'react'; -import type { AvatarGroupProps, AvatarGroupState } from './AvatarGroup.types'; -import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities'; -import { PopoverSurface } from '@fluentui/react-popover'; -import { Button } from '@fluentui/react-button'; -import { avatarGroupDefaultStrings } from './AvatarGroup.strings'; - -/** - * Create the state required to render AvatarGroup. - * - * The returned state can be modified with hooks such as useAvatarGroupStyles_unstable, - * before being passed to renderAvatarGroup_unstable. - * - * @param props - props from this instance of AvatarGroup - * @param ref - reference to root HTMLElement of AvatarGroup - */ -export const useAvatarGroup_unstable = (props: AvatarGroupProps, ref: React.Ref): AvatarGroupState => { - const { - children, - layout = 'spread', - maxAvatars = 5, - overflowIndicator = 'number-overflowed', - size = 32, - strings = avatarGroupDefaultStrings, - } = props; - - return { - // TODO: Replace with actual logic. - layout, - maxAvatars, - overflowIndicator, - size, - tooltipContent: strings.tooltipContent.replace('{numOverflowedAvatars}', String(10)), - components: { - // TODO add each slot's element type or component - root: 'div', - popoverSurface: PopoverSurface, - popoverTrigger: Button, - }, - // TODO add appropriate slots, for example: - // mySlot: resolveShorthand(props.mySlot), - root: getNativeElementProps('div', { - ref, - ...props, - }), - popoverTrigger: resolveShorthand(props.popoverTrigger, { - required: true, - defaultProps: { - // TODO: Update children - children: '+10', - }, - }), - popoverSurface: resolveShorthand(props.popoverSurface, { - required: true, - defaultProps: { - // TODO: Update children - children: children, - }, - }), - }; -}; diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroup.tsx b/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroup.tsx new file mode 100644 index 00000000000000..943ba7dfc84e1f --- /dev/null +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroup.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { getNativeElementProps, resolveShorthand } from '@fluentui/react-utilities'; +import { MoreHorizontalRegular } from '@fluentui/react-icons'; +import { PopoverSurface } from '@fluentui/react-popover'; +import type { AvatarGroupProps, AvatarGroupState } from './AvatarGroup.types'; + +/** + * Create the state required to render AvatarGroup. + * + * The returned state can be modified with hooks such as useAvatarGroupStyles_unstable, + * before being passed to renderAvatarGroup_unstable. + * + * @param props - props from this instance of AvatarGroup + * @param ref - reference to root HTMLElement of AvatarGroup + */ +export const useAvatarGroup_unstable = (props: AvatarGroupProps, ref: React.Ref): AvatarGroupState => { + const { children, layout = 'spread', maxAvatars = 5, size = 32 } = props; + const { overflowIndicator = size < 24 ? 'icon' : 'count' } = props; + const childrenArray = React.Children.toArray(children); + + let rootChildren = childrenArray; + let overflowChildren; + let overflowButtonChildren; + + if (layout === 'pie') { + rootChildren = childrenArray.slice(0, 3); + overflowChildren = childrenArray; + } else if (childrenArray.length > maxAvatars) { + const numOfAvatarsToHide = childrenArray.length - maxAvatars + 1; + + rootChildren = childrenArray.slice(numOfAvatarsToHide); + overflowChildren = childrenArray.slice(0, numOfAvatarsToHide); + + if (overflowIndicator === 'icon') { + overflowButtonChildren = ; + } else { + overflowButtonChildren = numOfAvatarsToHide > 99 ? '99+' : `+${numOfAvatarsToHide}`; + } + } + + const root = getNativeElementProps( + 'div', + { + role: 'group', + ...props, + ref, + children: rootChildren, + }, + ['size'], + ); + + const overflowButton = resolveShorthand(props.overflowButton, { + required: true, + defaultProps: { + children: overflowButtonChildren, + }, + }); + + const overflowSurface = resolveShorthand(props.overflowSurface, { + required: true, + defaultProps: { + 'aria-label': 'Overflow', + }, + }); + + const overflowList = resolveShorthand(props.overflowList, { + required: true, + defaultProps: { + children: overflowChildren, + role: 'list', + tabIndex: 0, + }, + }); + + return { + hasOverflow: !!overflowChildren, + layout, + overflowIndicator, + size, + tooltipContent: 'View more people.', + + components: { + root: 'div', + overflowSurface: PopoverSurface, + overflowList: 'ul', + overflowButton: 'button', + }, + + root, + overflowButton, + overflowSurface, + overflowList, + }; +}; diff --git a/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroupStyles.ts b/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroupStyles.ts index 6bee54f32282b7..64b74b8e46b354 100644 --- a/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroupStyles.ts +++ b/packages/react-components/react-avatar/src/components/AvatarGroup/useAvatarGroupStyles.ts @@ -1,44 +1,65 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { useSizeStyles } from '../Avatar/useAvatarStyles'; import type { AvatarGroupSlots, AvatarGroupState } from './AvatarGroup.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; export const avatarGroupClassNames: SlotClassNames = { root: 'fui-AvatarGroup', - popoverSurface: 'fui-AvatarGroup__popoverSurface', - popoverTrigger: 'fui-AvatarGroup__popoverTrigger', -}; - -export const extraAvatarGroupClassNames = { - popoverSurfaceItem: 'fui-AvatarGroup__popoverSurfaceItem', + overflowSurface: 'fui-AvatarGroup__overflowSurface', + overflowList: 'fui-AvatarGroup__overflowList', + overflowButton: 'fui-AvatarGroup__overflowButton', }; /** - * Styles for the root slot + * Styles for the root slot. */ const useStyles = makeStyles({ - root: { - // TODO Add default styles for the root element + base: { + display: 'inline-flex', + position: 'relative', }, +}); - // TODO add additional classes for different states and/or slots +const useOverflowListStyles = makeStyles({ + base: { + listStyleType: 'none', + ...shorthands.margin(0), + ...shorthands.padding(0), + }, }); /** * Apply styling to the AvatarGroup slots based on the state */ export const useAvatarGroupStyles_unstable = (state: AvatarGroupState): AvatarGroupState => { + const { size } = state; const styles = useStyles(); - state.root.className = mergeClasses(avatarGroupClassNames.root, styles.root, state.root.className); + const sizeStyles = useSizeStyles(); + const overflowListStyles = useOverflowListStyles(); + + state.root.className = mergeClasses(avatarGroupClassNames.root, styles.base, state.root.className); - // TODO Add class names to slots, for example: - // state.mySlot.className = mergeClasses(styles.mySlot, state.mySlot.className); + if (state.overflowSurface) { + state.overflowSurface.className = mergeClasses( + avatarGroupClassNames.overflowSurface, + state.overflowSurface.className, + ); + } - if (state.popoverSurface) { - state.popoverSurface.className = mergeClasses(avatarGroupClassNames.popoverSurface, state.popoverSurface.className); + if (state.overflowList) { + state.overflowList.className = mergeClasses( + avatarGroupClassNames.overflowList, + overflowListStyles.base, + state.overflowList.className, + ); } - if (state.popoverTrigger) { - state.popoverTrigger.className = mergeClasses(avatarGroupClassNames.popoverTrigger, state.popoverTrigger.className); + if (state.overflowButton) { + state.overflowButton.className = mergeClasses( + avatarGroupClassNames.overflowButton, + sizeStyles[size], + state.overflowButton.className, + ); } return state; diff --git a/packages/react-components/react-avatar/src/stories/AvatarGroup/AvatarGroupDefault.stories.tsx b/packages/react-components/react-avatar/src/stories/AvatarGroup/AvatarGroupDefault.stories.tsx index 5e371156efdfa9..60cf5fc5ef29ea 100644 --- a/packages/react-components/react-avatar/src/stories/AvatarGroup/AvatarGroupDefault.stories.tsx +++ b/packages/react-components/react-avatar/src/stories/AvatarGroup/AvatarGroupDefault.stories.tsx @@ -1,4 +1,20 @@ import * as React from 'react'; -import { AvatarGroup, AvatarGroupProps } from '../../index'; +import { AvatarGroup } from '../../AvatarGroup'; +import { AvatarGroupItem } from '../../AvatarGroupItem'; +import type { AvatarGroupProps } from '../../AvatarGroup'; -export const Default = (props: Partial) => ; +export const Default = (props: Partial) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/react-components/react-avatar/src/stories/AvatarGroupItem/AvatarGroupItemDefault.stories.tsx b/packages/react-components/react-avatar/src/stories/AvatarGroupItem/AvatarGroupItemDefault.stories.tsx index 8195c576a36701..379b2b98b25437 100644 --- a/packages/react-components/react-avatar/src/stories/AvatarGroupItem/AvatarGroupItemDefault.stories.tsx +++ b/packages/react-components/react-avatar/src/stories/AvatarGroupItem/AvatarGroupItemDefault.stories.tsx @@ -1,4 +1,4 @@ import * as React from 'react'; -import { AvatarGroupItem, AvatarGroupItemProps } from '../../index'; +import { AvatarGroupItem, AvatarGroupItemProps } from '../../AvatarGroupItem'; export const Default = (props: Partial) => ;