Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Avatar's aria label includes 'active' or 'inactive' when using the active prop #24901

Merged
merged 4 commits into from
Sep 26, 2022
Merged
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 @@
{
"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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export type AvatarSlots = {
// @public
export type AvatarState = ComponentState<AvatarSlots> & Required<Pick<AvatarProps, 'active' | 'activeAppearance' | 'shape' | 'size'>> & {
color: NonNullable<Exclude<AvatarProps['color'], 'colorful'>>;
activeAriaLabelElement?: JSX.Element;
};

// @internal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -175,25 +176,81 @@ 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(<Avatar initials={{ children: 'FL', id: 'initials-id' }} />);

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(<Avatar initials="ABC" />);

const intialsId = screen.getByText('ABC').id;

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(<Avatar id="root-id" name={name} badge={{ status: 'away', id: 'badge-id' }} />);

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(<Avatar id="root-id" name={name} active="active" />);

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(<Avatar id="root-id" name={name} active="inactive" />);

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(<Avatar id="root-id" name="First Last" badge={{ status: 'away', id: 'badge-id' }} active="active" />);

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(
<Avatar
initials={{ children: 'FL', id: 'initials-id' }}
badge={{ status: 'away', id: 'badge-id' }}
active="inactive"
/>,
);

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(<Avatar name="First Last" />);

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,9 @@ export type AvatarState = ComponentState<AvatarSlots> &
* The Avatar's color, it matches props.color but with `'colorful'` resolved to a named color
*/
color: NonNullable<Exclude<AvatarProps['color'], 'colorful'>>;

/**
* Hidden span to render the active state label for the purposes of including in the aria-labelledby, if needed.
*/
activeAriaLabelElement?: JSX.Element;
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const renderAvatar_unstable = (state: AvatarState) => {
{slots.icon && <slots.icon {...slotProps.icon} />}
{slots.image && <slots.image {...slotProps.image} />}
{slots.badge && <slots.badge {...slotProps.badge} />}
{state.activeAriaLabelElement}
</slots.root>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>): AvatarState => {
const { dir } = useFluent();
const { name, size = 32, shape = 'circular', active = 'unset', activeAppearance = 'ring', idForColor } = props;
Expand Down Expand Up @@ -75,6 +80,8 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref<HTMLElemen
},
});

let activeAriaLabelElement: AvatarState['activeAriaLabelElement'];

// Resolve aria-label and/or aria-labelledby if not provided by the user
if (!root['aria-label'] && !root['aria-labelledby']) {
if (name) {
Expand All @@ -88,13 +95,32 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref<HTMLElemen
// 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;
}
}
}

return {
size,
shape,
active,
activeAppearance,
activeAriaLabelElement,
color,

components: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Avatar } from '@fluentui/react-avatar';

export const Active = () => (
<div style={{ display: 'flex', gap: '20px' }}>
<Avatar active="active" name="Active" />
<Avatar active="inactive" name="Inactive" />
<Avatar active="active" name="Ashley McCarthy" />
<Avatar active="inactive" name="Isaac Fielder" badge={{ status: 'away' }} />
</div>
);

Expand Down