From 560dfec1b8da6121ae947c06f647ead5acd1cc3f Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 23 Jan 2026 22:10:45 +0100 Subject: [PATCH] feat(react-avatar): add base hooks for Avatar component --- ...-7185a162-9726-469e-9d62-b6debdb55f19.json | 7 + .../react-avatar/library/src/Avatar.ts | 3 + .../src/components/Avatar/Avatar.types.ts | 4 + .../library/src/components/Avatar/index.ts | 4 +- .../src/components/Avatar/useAvatar.tsx | 191 ++++++++++++------ .../AvatarGroupItem/useAvatarGroupItem.ts | 4 +- .../react-avatar/library/src/index.ts | 4 + 7 files changed, 152 insertions(+), 65 deletions(-) create mode 100644 change/@fluentui-react-avatar-7185a162-9726-469e-9d62-b6debdb55f19.json diff --git a/change/@fluentui-react-avatar-7185a162-9726-469e-9d62-b6debdb55f19.json b/change/@fluentui-react-avatar-7185a162-9726-469e-9d62-b6debdb55f19.json new file mode 100644 index 00000000000000..bec09b6466699d --- /dev/null +++ b/change/@fluentui-react-avatar-7185a162-9726-469e-9d62-b6debdb55f19.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add base hooks for Avatar component", + "packageName": "@fluentui/react-avatar", + "email": "dmytrokirpa@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-avatar/library/src/Avatar.ts b/packages/react-components/react-avatar/library/src/Avatar.ts index 642b24efddd5c4..afbec94c4c65e4 100644 --- a/packages/react-components/react-avatar/library/src/Avatar.ts +++ b/packages/react-components/react-avatar/library/src/Avatar.ts @@ -1,11 +1,13 @@ export type { AvatarNamedColor, + AvatarBaseProps, AvatarProps, AvatarShape, AvatarSize, // eslint-disable-next-line @typescript-eslint/no-deprecated AvatarSizes, AvatarSlots, + AvatarBaseState, AvatarState, } from './components/Avatar/index'; export { @@ -15,5 +17,6 @@ export { renderAvatar_unstable, useAvatarStyles_unstable, useAvatar_unstable, + useAvatarBase_unstable, useSizeStyles, } from './components/Avatar/index'; diff --git a/packages/react-components/react-avatar/library/src/components/Avatar/Avatar.types.ts b/packages/react-components/react-avatar/library/src/components/Avatar/Avatar.types.ts index 82b46d491ed866..e7abd99e17b17a 100644 --- a/packages/react-components/react-avatar/library/src/components/Avatar/Avatar.types.ts +++ b/packages/react-components/react-avatar/library/src/components/Avatar/Avatar.types.ts @@ -155,6 +155,8 @@ export type AvatarProps = Omit, 'color'> & { size?: AvatarSize; }; +export type AvatarBaseProps = ComponentProps> & Pick; + /** * State used in rendering Avatar */ @@ -170,3 +172,5 @@ export type AvatarState = ComponentState & */ activeAriaLabelElement?: JSXElement; }; + +export type AvatarBaseState = ComponentState> & Pick; diff --git a/packages/react-components/react-avatar/library/src/components/Avatar/index.ts b/packages/react-components/react-avatar/library/src/components/Avatar/index.ts index 410a795a32eb43..a7a0f809b2bf81 100644 --- a/packages/react-components/react-avatar/library/src/components/Avatar/index.ts +++ b/packages/react-components/react-avatar/library/src/components/Avatar/index.ts @@ -1,14 +1,16 @@ export type { AvatarNamedColor, + AvatarBaseProps, AvatarProps, AvatarShape, AvatarSize, // eslint-disable-next-line @typescript-eslint/no-deprecated AvatarSizes, AvatarSlots, + AvatarBaseState, AvatarState, } from './Avatar.types'; export { Avatar } from './Avatar'; export { renderAvatar_unstable } from './renderAvatar'; -export { DEFAULT_STRINGS, useAvatar_unstable } from './useAvatar'; +export { DEFAULT_STRINGS, useAvatar_unstable, useAvatarBase_unstable } from './useAvatar'; export { avatarClassNames, useAvatarStyles_unstable, useSizeStyles } from './useAvatarStyles.styles'; diff --git a/packages/react-components/react-avatar/library/src/components/Avatar/useAvatar.tsx b/packages/react-components/react-avatar/library/src/components/Avatar/useAvatar.tsx index ed2b9e42397acd..198e878794630e 100644 --- a/packages/react-components/react-avatar/library/src/components/Avatar/useAvatar.tsx +++ b/packages/react-components/react-avatar/library/src/components/Avatar/useAvatar.tsx @@ -1,9 +1,9 @@ 'use client'; import * as React from 'react'; -import { getIntrinsicElementProps, mergeCallbacks, useId, slot } from '@fluentui/react-utilities'; +import { mergeCallbacks, useId, slot } from '@fluentui/react-utilities'; import { getInitials } from '../../utils/index'; -import type { AvatarNamedColor, AvatarProps, AvatarState } from './Avatar.types'; +import type { AvatarBaseProps, AvatarBaseState, AvatarNamedColor, AvatarProps, AvatarState } from './Avatar.types'; import { PersonRegular } from '@fluentui/react-icons'; import { PresenceBadge } from '@fluentui/react-badge'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; @@ -18,114 +18,181 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref; + } + + const badge: AvatarState['badge'] = slot.optional(props.badge, { + defaultProps: { size: getBadgeSize(size), id: state.root.id + '__badge' }, + elementType: PresenceBadge, + }); + + let activeAriaLabelElement: AvatarState['activeAriaLabelElement'] = state.activeAriaLabelElement; + + // Enhance aria-label and/or aria-labelledby to include badge and active state + // Only process if aria attributes were not explicitly provided by the user + const userProvidedAriaLabel = props['aria-label'] !== undefined; + const userProvidedAriaLabelledby = props['aria-labelledby'] !== undefined; + + if (!userProvidedAriaLabel && !userProvidedAriaLabelledby) { + if (props.name) { + // Include the badge in labelledby if it exists + if (badge) { + state.root['aria-labelledby'] = state.root.id + ' ' + badge.id; + } + // aria-label is already set by base function, keep it + } else if (state.initials) { + // root's aria-label should be the name, but fall back to being labelledby the initials if name is missing + state.root['aria-labelledby'] = state.initials.id + (badge ? ' ' + badge.id : ''); + delete state.root['aria-label']; + } + // Add the active state to the aria label + if (active === 'active' || active === 'inactive') { + const activeText = DEFAULT_STRINGS[active]; + if (state.root['aria-labelledby']) { + // If using aria-labelledby, render a hidden span and append it to the labelledby + const activeId = state.root.id + '__active'; + state.root['aria-labelledby'] += ' ' + activeId; + activeAriaLabelElement = ( + + ); + } else if (state.root['aria-label']) { + // Otherwise, just append it to the aria-label + state.root['aria-label'] += ' ' + activeText; + } + } + } + + return { + ...state, + size, + shape, + active, + activeAppearance, + activeAriaLabelElement, + color, + badge, + // eslint-disable-next-line @typescript-eslint/no-deprecated + components: { ...state.components, badge: PresenceBadge }, + }; +}; + +/** + * Base hook for Avatar component, manages state and structure common to all variants of Avatar + */ +export const useAvatarBase_unstable = (props: AvatarBaseProps, ref?: React.Ref): AvatarBaseState => { + const { dir } = useFluent(); + const { name, ...rest } = props; + const baseId = useId('avatar-'); - const root: AvatarState['root'] = slot.always( - getIntrinsicElementProps( - 'span', - { - role: 'img', - id: baseId, - // aria-label and/or aria-labelledby are resolved below - ...props, - ref, - }, - /* excludedPropNames: */ ['name'], - ), + const root: AvatarBaseState['root'] = slot.always( + { + role: 'img', + id: baseId, + ref, + ...rest, + }, { elementType: 'span' }, ); + const [imageHidden, setImageHidden] = React.useState(undefined); - let image: AvatarState['image'] = slot.optional(props.image, { + + let image: AvatarBaseState['image'] = slot.optional(props.image, { defaultProps: { alt: '', role: 'presentation', 'aria-hidden': true, hidden: imageHidden }, elementType: 'img', - }); // Image shouldn't be rendered if its src is not set + }); + + // Image shouldn't be rendered if its src is not set if (!image?.src) { image = undefined; - } // Hide the image if it fails to load and restore it on a successful load + } + + // Hide the image if it fails to load and restore it on a successful load if (image) { image.onError = mergeCallbacks(image.onError, () => setImageHidden(true)); image.onLoad = mergeCallbacks(image.onLoad, () => setImageHidden(undefined)); - } // Resolve the initials slot, defaulted to getInitials. - let initials: AvatarState['initials'] = slot.optional(props.initials, { + } + + // Resolve the initials slot, defaulted to getInitials + let initials: AvatarBaseState['initials'] = slot.optional(props.initials, { renderByDefault: true, defaultProps: { - children: getInitials(name, dir === 'rtl', { firstInitialOnly: size <= 16 }), + children: getInitials(name, dir === 'rtl'), id: baseId + '__initials', }, elementType: 'span', - }); // Don't render the initials slot if it's empty + }); + + // Don't render the initials slot if it's empty if (!initials?.children) { initials = undefined; - } // Render the icon slot *only if* there aren't any initials or image to display - let icon: AvatarState['icon'] = undefined; + } + + // Render the icon slot *only if* there aren't any initials or image to display + let icon: AvatarBaseState['icon'] = undefined; if (!initials && (!image || imageHidden)) { icon = slot.optional(props.icon, { - renderByDefault: true, - defaultProps: { children: , 'aria-hidden': true }, + defaultProps: { + 'aria-hidden': true, + }, elementType: 'span', }); } - const badge: AvatarState['badge'] = slot.optional(props.badge, { - defaultProps: { size: getBadgeSize(size), id: baseId + '__badge' }, - elementType: PresenceBadge, - }); - let activeAriaLabelElement: AvatarState['activeAriaLabelElement']; // Resolve aria-label and/or aria-labelledby if not provided by the user + + let activeAriaLabelElement: AvatarBaseState['activeAriaLabelElement']; + + // Resolve aria-label and/or aria-labelledby if not provided by the user if (!root['aria-label'] && !root['aria-labelledby']) { if (name) { - root['aria-label'] = name; // Include the badge in labelledby if it exists - if (badge) { - root['aria-labelledby'] = root.id + ' ' + badge.id; - } + root['aria-label'] = name; } else if (initials) { // root's aria-label should be the name, but fall back to being labelledby the initials if name is missing - root['aria-labelledby'] = initials.id + (badge ? ' ' + badge.id : ''); - } // Add the active state to the aria label - if (active === 'active' || active === 'inactive') { - const activeText = DEFAULT_STRINGS[active]; - if (root['aria-labelledby']) { - // If using aria-labelledby, render a hidden span and append it to the labelledby - const activeId = baseId + '__active'; - root['aria-labelledby'] += ' ' + activeId; - activeAriaLabelElement = ( - - ); - } else if (root['aria-label']) { - // Otherwise, just append it to the aria-label - root['aria-label'] += ' ' + activeText; - } + root['aria-labelledby'] = initials.id; } } + return { - size, - shape, - active, - activeAppearance, activeAriaLabelElement, - color, - components: { root: 'span', initials: 'span', icon: 'span', image: 'img', badge: PresenceBadge }, + components: { root: 'span', initials: 'span', icon: 'span', image: 'img' }, root, initials, icon, image, - badge, }; }; + const getBadgeSize = (size: AvatarState['size']) => { if (size >= 96) { return 'extra-large'; diff --git a/packages/react-components/react-avatar/library/src/components/AvatarGroupItem/useAvatarGroupItem.ts b/packages/react-components/react-avatar/library/src/components/AvatarGroupItem/useAvatarGroupItem.ts index 85a7dc0ed18e62..4401d5a0decd48 100644 --- a/packages/react-components/react-avatar/library/src/components/AvatarGroupItem/useAvatarGroupItem.ts +++ b/packages/react-components/react-avatar/library/src/components/AvatarGroupItem/useAvatarGroupItem.ts @@ -25,7 +25,7 @@ export const useAvatarGroupItem_unstable = ( const groupSize = useAvatarGroupContext_unstable(ctx => ctx.size); const layout = useAvatarGroupContext_unstable(ctx => ctx.layout); // Since the primary slot is not an intrinsic element, getPartitionedNativeProps cannot be used here. - const { style, className, ...avatarSlotProps } = props; + const { style, className, overflowLabel, ...avatarSlotProps } = props; const size = groupSize ?? defaultAvatarGroupSize; const hasAvatarGroupContext = useHasParentContext(AvatarGroupContext); @@ -59,7 +59,7 @@ export const useAvatarGroupItem_unstable = ( }, elementType: Avatar, }), - overflowLabel: slot.always(props.overflowLabel, { + overflowLabel: slot.always(overflowLabel, { defaultProps: { // Avatar already has its aria-label set to the name, this will prevent the name to be read twice. 'aria-hidden': true, diff --git a/packages/react-components/react-avatar/library/src/index.ts b/packages/react-components/react-avatar/library/src/index.ts index 6575eeafb28f75..e2f6558b5957f0 100644 --- a/packages/react-components/react-avatar/library/src/index.ts +++ b/packages/react-components/react-avatar/library/src/index.ts @@ -56,3 +56,7 @@ export { useAvatarGroupContext_unstable, } from './contexts/index'; export type { AvatarContextValue } from './contexts/index'; + +// Experimental APIs, will be undocumented in experimental branch +// export { useAvatarBase_unstable } from './Avatar'; +// export type { AvatarBaseProps, AvatarBaseState } from './Avatar';