diff --git a/change/@fluentui-react-avatar-e4aa8633-f04d-4761-9b6f-d7d78497c1c6.json b/change/@fluentui-react-avatar-e4aa8633-f04d-4761-9b6f-d7d78497c1c6.json new file mode 100644 index 0000000000000..15092842b62c4 --- /dev/null +++ b/change/@fluentui-react-avatar-e4aa8633-f04d-4761-9b6f-d7d78497c1c6.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: Avatar's aria label includes 'active' or 'inactive' when using the active prop", + "packageName": "@fluentui/react-avatar", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} 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 5779770c5074c..5c2ada78a5e84 100644 --- a/packages/react-components/react-avatar/etc/react-avatar.api.md +++ b/packages/react-components/react-avatar/etc/react-avatar.api.md @@ -141,6 +141,7 @@ export type AvatarSlots = { // @public export type AvatarState = ComponentState & Required> & { color: NonNullable>; + activeAriaLabelElement?: JSX.Element; }; // @internal diff --git a/packages/react-components/react-avatar/src/components/Avatar/Avatar.test.tsx b/packages/react-components/react-avatar/src/components/Avatar/Avatar.test.tsx index 0713f920e01e7..474c44c74226e 100644 --- a/packages/react-components/react-avatar/src/components/Avatar/Avatar.test.tsx +++ b/packages/react-components/react-avatar/src/components/Avatar/Avatar.test.tsx @@ -3,6 +3,7 @@ import { isConformant } from '../../common/isConformant'; import { Avatar } from './Avatar'; import { render, screen } from '@testing-library/react'; import { avatarClassNames } from './useAvatarStyles'; +import { DEFAULT_STRINGS } from './useAvatar'; describe('Avatar', () => { isConformant({ @@ -175,13 +176,13 @@ describe('Avatar', () => { expect(iconRef.current?.getAttribute('aria-hidden')).toBeTruthy(); }); - it('falls back to initials for aria-labelledby', () => { + it('sets aria-labelledby to initials if no name is provided', () => { render(); expect(screen.getByRole('img').getAttribute('aria-labelledby')).toBe('initials-id'); }); - it('falls back to string initials for aria-labelledby', () => { + it('sets aria-labelledby to initials with a generated ID, if no name is provided', () => { render(); const intialsId = screen.getByText('ABC').id; @@ -189,11 +190,67 @@ describe('Avatar', () => { expect(screen.getByRole('img').getAttribute('aria-labelledby')).toBe(intialsId); }); - it('includes badge in aria-labelledby', () => { + it('sets aria-labelledby to the name + badge', () => { const name = 'First Last'; render(); - expect(screen.getAllByRole('img')[0].getAttribute('aria-label')).toBe(name); - expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBe('root-id badge-id'); + const root = screen.getAllByRole('img')[0]; + expect(root.getAttribute('aria-label')).toBe(name); + expect(root.getAttribute('aria-labelledby')).toBe('root-id badge-id'); + }); + + it('sets aria-label to the name + activeState when active="active"', () => { + const name = 'First Last'; + render(); + + const root = screen.getAllByRole('img')[0]; + expect(root.getAttribute('aria-label')).toBe(`${name} ${DEFAULT_STRINGS.active}`); + }); + + it('sets aria-label to the name + activeState when active="inactive"', () => { + const name = 'First Last'; + render(); + + const root = screen.getAllByRole('img')[0]; + expect(root.getAttribute('aria-label')).toBe(`${name} ${DEFAULT_STRINGS.inactive}`); + }); + + it('sets aria-labelledby to the name + badge + activeState when there is a badge and active state', () => { + render(); + + const activeAriaLabelElement = screen.getByText(DEFAULT_STRINGS.active); + expect(activeAriaLabelElement.id).toBeTruthy(); + expect(activeAriaLabelElement.hidden).toBeTruthy(); + + const root = screen.getAllByRole('img')[0]; + expect(root.getAttribute('aria-labelledby')).toBe(`root-id badge-id ${activeAriaLabelElement.id}`); + }); + + it('sets aria-labelledby to the initials + badge + activeState, if no name is provided', () => { + render( + , + ); + + const activeAriaLabelElement = screen.getByText(DEFAULT_STRINGS.inactive); + expect(activeAriaLabelElement.id).toBeTruthy(); + expect(activeAriaLabelElement.hidden).toBeTruthy(); + + const root = screen.getAllByRole('img')[0]; + expect(root.getAttribute('aria-labelledby')).toBe(`initials-id badge-id ${activeAriaLabelElement.id}`); + }); + + it('does not render an activeAriaLabelElement when active state is unset', () => { + render(); + + expect(screen.queryByText(DEFAULT_STRINGS.active)).toBeNull(); + expect(screen.queryByText(DEFAULT_STRINGS.inactive)).toBeNull(); + + const root = screen.getAllByRole('img')[0]; + expect(root.getAttribute('aria-label')).toBe('First Last'); + expect(root.getAttribute('aria-labelledby')).toBeFalsy(); }); }); diff --git a/packages/react-components/react-avatar/src/components/Avatar/Avatar.types.ts b/packages/react-components/react-avatar/src/components/Avatar/Avatar.types.ts index 5c68e0636cc6e..7e4370ed34050 100644 --- a/packages/react-components/react-avatar/src/components/Avatar/Avatar.types.ts +++ b/packages/react-components/react-avatar/src/components/Avatar/Avatar.types.ts @@ -154,4 +154,9 @@ export type AvatarState = ComponentState & * The Avatar's color, it matches props.color but with `'colorful'` resolved to a named color */ color: NonNullable>; + + /** + * Hidden span to render the active state label for the purposes of including in the aria-labelledby, if needed. + */ + activeAriaLabelElement?: JSX.Element; }; diff --git a/packages/react-components/react-avatar/src/components/Avatar/renderAvatar.tsx b/packages/react-components/react-avatar/src/components/Avatar/renderAvatar.tsx index 98beddfbd043f..1a68971339b9b 100644 --- a/packages/react-components/react-avatar/src/components/Avatar/renderAvatar.tsx +++ b/packages/react-components/react-avatar/src/components/Avatar/renderAvatar.tsx @@ -11,6 +11,7 @@ export const renderAvatar_unstable = (state: AvatarState) => { {slots.icon && } {slots.image && } {slots.badge && } + {state.activeAriaLabelElement} ); }; diff --git a/packages/react-components/react-avatar/src/components/Avatar/useAvatar.tsx b/packages/react-components/react-avatar/src/components/Avatar/useAvatar.tsx index 5769e3fc3c724..96cb7692c5a27 100644 --- a/packages/react-components/react-avatar/src/components/Avatar/useAvatar.tsx +++ b/packages/react-components/react-avatar/src/components/Avatar/useAvatar.tsx @@ -6,6 +6,11 @@ import { PersonRegular } from '@fluentui/react-icons'; import { PresenceBadge } from '@fluentui/react-badge'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; +export const DEFAULT_STRINGS = { + active: 'active', + inactive: 'inactive', +}; + export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref): AvatarState => { const { dir } = useFluent(); const { name, size = 32, shape = 'circular', active = 'unset', activeAppearance = 'ring', idForColor } = props; @@ -75,6 +80,8 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref