Skip to content

Commit

Permalink
feat: Avatar's aria label includes 'active' or 'inactive' when using …
Browse files Browse the repository at this point in the history
…the active prop (#24901)

Append "active" or "inactive" to the aria label when the `active` prop is set. 
* When the Avatar is using the `aria-label` prop, simply append the string to it.
* If the Avatar needs to use `aria-labelledby`, then render a hidden span with the active label, and append the span's ID to `aria-labelledby`

There is no prop to control the strings yet; they are hardcoded for now. In the future, they will be able to be localized once we implement an i18n solution.
  • Loading branch information
behowell authored Sep 26, 2022
1 parent 203e5de commit 1dcb695
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 7 deletions.
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": "[email protected]",
"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

0 comments on commit 1dcb695

Please sign in to comment.