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 2 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 @@ -119,6 +119,7 @@ export type AvatarNamedColor = 'dark-red' | 'cranberry' | 'red' | 'pumpkin' | 'p
export type AvatarProps = Omit<ComponentProps<AvatarSlots>, 'color'> & {
active?: 'active' | 'inactive' | 'unset';
activeAppearance?: 'ring' | 'shadow' | 'ring-shadow';
activeAriaLabel?: string;
color?: 'neutral' | 'brand' | 'colorful' | AvatarNamedColor;
idForColor?: string | undefined;
name?: string;
Expand All @@ -136,6 +137,7 @@ export type AvatarSlots = {
initials?: Slot<'span'>;
icon?: Slot<'span'>;
badge?: Slot<typeof PresenceBadge>;
activeAriaLabel?: Slot<'span'>;
};

// @public
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 avatar + 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');
});

it('sets aria-labelledby to the avatar + activeAriaLabel when active="active"', () => {
render(<Avatar id="root-id" name="First Last" active="active" activeAriaLabel={{ id: 'active-id' }} />);

const activeAriaLabel = screen.getByText(DEFAULT_STRINGS.active);

expect(activeAriaLabel.id).toBe('active-id');
expect(activeAriaLabel.hidden).toBeTruthy();
expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBe('root-id active-id');
});

it('sets aria-labelledby to the avatar + activeAriaLabel when active="inactive"', () => {
render(<Avatar id="root-id" name="First Last" active="inactive" />);

const activeAriaLabel = screen.getByText(DEFAULT_STRINGS.inactive);
expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBe(`root-id ${activeAriaLabel.id}`);
});

it('sets aria-labelledby to the avatar + activeAriaLabel, when custom activeAriaLabel is provided', () => {
const customActiveAriaLabelText = 'custom active aria label';
render(<Avatar id="root-id" name="First Last" active="active" activeAriaLabel={customActiveAriaLabelText} />);

const activeAriaLabel = screen.getByText('hello world');
expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBe(`root-id ${activeAriaLabel.id}`);
});

it('sets aria-labelledby to the avatar + badge + activeAriaLabel', () => {
render(<Avatar id="root-id" name="First Last" badge={{ status: 'away', id: 'badge-id' }} active="active" />);

const activeAriaLabel = screen.getByText(DEFAULT_STRINGS.active);

expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBe(
`root-id badge-id ${activeAriaLabel.id}`,
);
});

it('sets aria-labelledby to the initials + badge + activeAriaLabel, if no name is provided', () => {
render(
<Avatar
initials={{ children: 'FL', id: 'initials-id' }}
badge={{ status: 'away', id: 'badge-id' }}
activeAriaLabel={{ id: 'active-id' }}
active="inactive"
/>,
);

expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBe('initials-id badge-id active-id');
});

it('does not render an activeAriaLabel when active state is unset', () => {
const nonRenderedActiveAriaLabelText = 'this should not be rendered';
render(<Avatar name="First Last" activeAriaLabel={nonRenderedActiveAriaLabelText} />);

expect(screen.queryByText(nonRenderedActiveAriaLabelText)).toBeFalsy();
expect(screen.getAllByRole('img')[0].getAttribute('aria-labelledby')).toBeFalsy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export type AvatarSlots = {
* Badge to show the avatar's presence status.
*/
badge?: Slot<typeof PresenceBadge>;

/**
* Hidden text that is appended to the generated aria-labelledby to describe the active state.
* This will be ignored if a custom `aria-label` or `aria-labelledby` is set on this Avatar.
*
* The default value depends on the `active` prop:
* * unset: `undefined`
* * active: `strings.active`
* * inactive: `strings.inactive`
*/
activeAriaLabel?: Slot<'span'>;
behowell marked this conversation as resolved.
Show resolved Hide resolved
};

/**
Expand Down
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} />}
{slots.activeAriaLabel && <slots.activeAriaLabel {...slotProps.activeAriaLabel} />}
</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,18 +80,32 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref<HTMLElemen
},
});

// The activeAriaLabel slot is only used if there is an active state and no user-defined aria label
let activeAriaLabel;

// Resolve aria-label and/or aria-labelledby if not provided by the user
if (!root['aria-label'] && !root['aria-labelledby']) {
if (active === 'active' || active === 'inactive') {
activeAriaLabel = resolveShorthand(props.activeAriaLabel, {
required: true,
defaultProps: {
children: DEFAULT_STRINGS[active],
id: baseId + '__activeAriaLabel',
hidden: true,
},
});
}

if (name) {
root['aria-label'] = name;

// Include the badge in labelledby if it exists
if (badge) {
root['aria-labelledby'] = root.id + ' ' + badge.id;
// Include the badge and/or activeAriaLabel in labelledby if they exist
if (badge || activeAriaLabel) {
root['aria-labelledby'] = [root.id, badge?.id, activeAriaLabel?.id].filter(id => id).join(' ');
}
} 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 : '');
root['aria-labelledby'] = [initials.id, badge?.id, activeAriaLabel?.id].filter(id => id).join(' ');
}
}

Expand All @@ -103,13 +122,15 @@ export const useAvatar_unstable = (props: AvatarProps, ref: React.Ref<HTMLElemen
icon: 'span',
image: 'img',
badge: PresenceBadge,
activeAriaLabel: 'span',
},

root,
initials,
icon,
image,
badge,
activeAriaLabel,
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const avatarClassNames: SlotClassNames<AvatarSlots> = {
initials: 'fui-Avatar__initials',
icon: 'fui-Avatar__icon',
badge: 'fui-Avatar__badge',
activeAriaLabel: 'fui-Avatar__activeAriaLabel',
};

//
Expand Down Expand Up @@ -492,5 +493,9 @@ export const useAvatarStyles_unstable = (state: AvatarState): AvatarState => {
);
}

if (state.activeAriaLabel) {
state.activeAriaLabel.className = mergeClasses(avatarClassNames.activeAriaLabel, state.activeAriaLabel.className);
}

return state;
};
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