Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Copy link

@github-actions github-actions bot Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Avatar Converged 10 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Avatar Converged.basic - High Contrast.normal.chromium.png 141 Changed
vr-tests-react-components/Avatar Converged.basic - RTL.normal.chromium.png 106 Changed
vr-tests-react-components/Avatar Converged.basic - Dark Mode.normal.chromium.png 30 Changed
vr-tests-react-components/Avatar Converged.image-bad-url+icon.normal.chromium.png 106 Changed
vr-tests-react-components/Avatar Converged.customSize+icon+active.normal.chromium.png 2826 Changed
vr-tests-react-components/Avatar Converged.color - High Contrast.normal.chromium.png 4504 Changed
vr-tests-react-components/Avatar Converged.color+active.normal.chromium.png 6586 Changed
vr-tests-react-components/Avatar Converged.size+icon+badge+square.normal.chromium.png 6366 Changed
vr-tests-react-components/Avatar Converged.color+active - Dark Mode.normal.chromium.png 2702 Changed
vr-tests-react-components/Avatar Converged.badgeMask.normal.chromium.png 5 Changed
vr-tests-react-components/Charts-DonutChart 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic - RTL.default.chromium.png 5570 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 760 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 28 Changed
vr-tests-react-components/Skeleton converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Skeleton converged.Opaque Skeleton with circle - High Contrast.default.chromium.png 1 Changed
vr-tests-react-components/TagPicker 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - High Contrast.disabled input hover.chromium.png 1319 Changed
vr-tests-react-components/Tree 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Tree.persona - High Contrast.default.chromium.png 1102 Changed
vr-tests-react-components/Tree.persona - Dark Mode.default.chromium.png 208 Changed
vr-tests-react-components/Tree.persona.default.chromium.png 828 Changed

There were 6 duplicate changes discarded. Check the build logs for more information.

"type": "minor",
"comment": "feat: add base hooks for Avatar component",
"packageName": "@fluentui/react-avatar",
"email": "dmytrokirpa@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -15,5 +17,6 @@ export {
renderAvatar_unstable,
useAvatarStyles_unstable,
useAvatar_unstable,
useAvatarBase_unstable,
useSizeStyles,
} from './components/Avatar/index';
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ export type AvatarProps = Omit<ComponentProps<AvatarSlots>, 'color'> & {
size?: AvatarSize;
};

export type AvatarBaseProps = ComponentProps<Omit<AvatarSlots, 'badge'>> & Pick<AvatarProps, 'name'>;

/**
* State used in rendering Avatar
*/
Expand All @@ -170,3 +172,5 @@ export type AvatarState = ComponentState<AvatarSlots> &
*/
activeAriaLabelElement?: JSXElement;
};

export type AvatarBaseState = ComponentState<Omit<AvatarSlots, 'badge'>> & Pick<AvatarState, 'activeAriaLabelElement'>;
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,114 +18,181 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref<HTMLElemen
const { dir } = useFluent();
const { shape: contextShape, size: contextSize } = useAvatarContext();
const {
name,
size = contextSize ?? (32 as const),
shape = contextShape ?? 'circular',
active = 'unset',
activeAppearance = 'ring',
idForColor,
color: propColor = 'neutral',
...rest
} = props;
let { color = 'neutral' } = props;

const state = useAvatarBase_unstable(rest, ref);

// Resolve 'colorful' to a specific color name
if (color === 'colorful') {
color = avatarColors[getHashCode(idForColor ?? name ?? '') % avatarColors.length];
const color: AvatarState['color'] =
propColor === 'colorful'
? avatarColors[getHashCode(idForColor ?? props.name ?? '') % avatarColors.length]
: propColor;

if (state.initials) {
state.initials = slot.optional(props.initials, {
renderByDefault: true,
defaultProps: {
children: getInitials(props.name, dir === 'rtl', { firstInitialOnly: size <= 16 }),
id: state.initials?.id,
},
elementType: 'span',
});
}

// Set default icon children if icon slot exists
if (state.icon) {
state.icon.children ??= <PersonRegular />;
}

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 = (
<span hidden id={activeId}>
{activeText}
</span>
);
} 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<HTMLElement>): 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<true | undefined>(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: <PersonRegular />, '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 = (
<span hidden id={activeId}>
{activeText}
</span>
);
} 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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/react-components/react-avatar/library/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';