Skip to content

fix(avatar): Avatar component now always includes alt attribute #5512

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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions .changeset/fifty-clocks-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@spectrum-web-components/avatar': minor
---

**Fixed** : Avatar component now always includes alt attribute for improved accessibility even when no label is specified
38 changes: 37 additions & 1 deletion packages/avatar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,40 @@ import { Avatar } from '@spectrum-web-components/avatar';

## Accessibility

The `label` attribute of the `<sp-avatar>` will be passed into the `<img>` element as the `alt` tag for use in defining a textual representation of the image displayed.
The Avatar component is designed to be accessible by default. To ensure proper accessibility:

1. Provide a `label` attribute when the avatar represents a user or has meaningful content:

```html
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
```

2. Use the `isdecorative` attribute when the avatar is purely decorative and should be hidden from screen readers:

```html
<sp-avatar isdecorative src="https://picsum.photos/500/500"></sp-avatar>
```

3. If neither `label` nor `isdecorative` is provided, a warning will be logged to the console to help developers identify accessibility issues.

### Accessibility Features

- When a `label` is provided, it is used as the `alt` text for the image
- When `isdecorative` is true, the avatar is hidden from screen readers using `aria-hidden="true"`
- The component maintains focus management for keyboard navigation
- Color contrast meets WCAG 2.1 Level AA requirements

### Accessibility Best Practices

- Always provide a `label` for avatars that represent users or have meaningful content
- Use `isdecorative` for purely decorative avatars
- Avoid using avatars without either a `label` or `isdecorative` attribute
- Ensure the avatar image has sufficient contrast with its background
- When using avatars in interactive contexts (e.g., as buttons), ensure they have appropriate ARIA roles and labels

### Best Practices

- Always provide a meaningful `label`
24 changes: 22 additions & 2 deletions packages/avatar/src/Avatar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import {
property,
query,
} from '@spectrum-web-components/base/src/decorators.js';
import { ifDefined } from '@spectrum-web-components/base/src/directives.js';
import { LikeAnchor } from '@spectrum-web-components/shared/src/like-anchor.js';
import { Focusable } from '@spectrum-web-components/shared/src/focusable.js';
import { ifDefined } from '@spectrum-web-components/base/src/directives.js';

import avatarStyles from './avatar.css.js';

Expand All @@ -48,6 +48,14 @@ export class Avatar extends LikeAnchor(Focusable) {
@property()
public src = '';

/**
* Whether this avatar is decorative and should be hidden from screen readers.
* When true, aria-hidden will be set to true and alt will be empty.
* When false and no label is provided, a warning will be logged.
*/
@property({ type: Boolean, reflect: true })
public isdecorative = false;

@property({ type: Number, reflect: true })
public get size(): AvatarSize {
return this._size;
Expand Down Expand Up @@ -75,7 +83,8 @@ export class Avatar extends LikeAnchor(Focusable) {
const avatar = html`
<img
class="image"
alt=${ifDefined(this.label || undefined)}
alt=${ifDefined(this.isdecorative ? '' : this.label)}
aria-hidden=${this.isdecorative ? 'true' : 'false'}
src=${this.src}
/>
`;
Expand All @@ -94,5 +103,16 @@ export class Avatar extends LikeAnchor(Focusable) {
if (!this.hasAttribute('size')) {
this.setAttribute('size', `${this.size}`);
}
// Log a warning if neither label nor isdecorative is set
if (!this.label && !this.isdecorative) {
window.__swc.warn(
this,
`<${this.localName}> is missing a label and is not marked as decorative. Either provide a label or set isdecorative="true" for accessibility.`,
'https://opensource.adobe.com/spectrum-web-components/components/avatar/#accessibility',
{
type: 'accessibility',
}
);
}
}
}
12 changes: 12 additions & 0 deletions packages/avatar/stories/avatar.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ export default {
disabled: { control: 'boolean' },
label: { control: 'text' },
src: { control: 'text' },
isdecorative: { control: 'boolean' },
},
args: {
disabled: false,
label: 'Place dog',
src: avatar,
isdecorative: false,
},
};

Expand All @@ -35,6 +37,7 @@ interface StoryArgs {
label?: string;
src?: string;
size?: AvatarSize;
isdecorative?: boolean;
}

const Template = ({
Expand Down Expand Up @@ -85,3 +88,12 @@ export const size700 = (args: StoryArgs = {}): TemplateResult =>
export const linked = (args: StoryArgs = {}): TemplateResult => Link(args);
export const disabled = (args: StoryArgs = {}): TemplateResult => Link(args);
disabled.args = { disabled: true };

export const decorative = (args: StoryArgs = {}): TemplateResult => html`
<sp-avatar
?isdecorative=${true}
src=${args.src || avatar}
size=${args.size || 100}
></sp-avatar>
`;
decorative.args = { isdecorative: true };
150 changes: 96 additions & 54 deletions packages/avatar/test/avatar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,57 +12,104 @@ import '@spectrum-web-components/avatar/sp-avatar.js';
import { Avatar } from '@spectrum-web-components/avatar';
import { elementUpdated, expect, fixture, html } from '@open-wc/testing';
import { testForLitDevWarnings } from '../../../test/testing-helpers';
import { spy } from 'sinon';

describe('Avatar', () => {
testForLitDevWarnings(
async () =>
await fixture<Avatar>(
html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`
)
);
it('loads accessibly', async () => {
const el = await fixture<Avatar>(
html`
await fixture<Avatar>(html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`
);
`)
);
it('loads accessibly with label', async () => {
const el = await fixture<Avatar>(html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`);

await elementUpdated(el);
await expect(el).to.be.accessible();

const imageEl = el.shadowRoot?.querySelector('img') as HTMLImageElement;
expect(imageEl.getAttribute('alt')).to.equal('Shantanu Narayen');
expect(imageEl.getAttribute('aria-hidden')).to.equal('false');
});
it('loads accessibly with isdecorative', async () => {
const el = await fixture<Avatar>(html`
<sp-avatar
isdecorative
src="https://picsum.photos/500/500"
></sp-avatar>
`);

await elementUpdated(el);
await expect(el).to.be.accessible();

const imageEl = el.shadowRoot?.querySelector('img') as HTMLImageElement;
expect(imageEl.getAttribute('alt')).to.equal('');
expect(imageEl.getAttribute('aria-hidden')).to.equal('true');
});
it('loads accessibly with [href]', async () => {
const el = await fixture<Avatar>(
html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
href="https://adobe.com"
></sp-avatar>
`

it('loads with warning when no label is provided', async () => {
const consoleSpy = spy(console, 'warn');
const el = await fixture<Avatar>(html`
<sp-avatar src="https://picsum.photos/500/500"></sp-avatar>
`);

await elementUpdated(el);
expect(consoleSpy.calledOnce).to.be.true;
expect(consoleSpy.firstCall.args[0]).to.include(
'Avatar is missing a label'
);

const imageEl = el.shadowRoot?.querySelector('img') as HTMLImageElement;
expect(imageEl.getAttribute('alt')).to.equal('');
// Should not be hidden unless explicitly decorative
expect(imageEl.getAttribute('aria-hidden')).to.equal('false');

consoleSpy.restore();
});

it('reflects isdecorative attribute', async () => {
const el = await fixture<Avatar>(html`
<sp-avatar
isdecorative
src="https://picsum.photos/500/500"
></sp-avatar>
`);

await elementUpdated(el);
expect(el.hasAttribute('isdecorative')).to.be.true;

el.isdecorative = false;
await elementUpdated(el);
expect(el.hasAttribute('isdecorative')).to.be.false;
});
it('loads accessibly with [href]', async () => {
const el = await fixture<Avatar>(html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
href="https://adobe.com"
></sp-avatar>
`);

await elementUpdated(el);

await expect(el).to.be.accessible();
});
it('validates `size`', async () => {
const el = await fixture<Avatar>(
html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`
);
const el = await fixture<Avatar>(html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`);

await elementUpdated(el);

Expand All @@ -81,14 +128,12 @@ describe('Avatar', () => {
expect(el.size).to.equal(600);
});
it('loads with everything set', async () => {
const el = await fixture<Avatar>(
html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`
);
const el = await fixture<Avatar>(html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
></sp-avatar>
`);

await elementUpdated(el);
expect(el).to.not.be.undefined;
Expand All @@ -99,30 +144,27 @@ describe('Avatar', () => {
expect(imageEl.getAttribute('alt')).to.equal('Shantanu Narayen');
});
it('loads with no label', async () => {
const el = await fixture<Avatar>(
html`
<sp-avatar src="https://picsum.photos/500/500"></sp-avatar>
`
);
const el = await fixture<Avatar>(html`
<sp-avatar src="https://picsum.photos/500/500"></sp-avatar>
`);

await elementUpdated(el);
expect(el).to.not.be.undefined;
const imageEl = el.shadowRoot
? (el.shadowRoot.querySelector('img') as HTMLImageElement)
: (el.querySelector('img') as HTMLImageElement);
expect(imageEl.hasAttribute('alt')).to.be.false;
expect(imageEl.hasAttribute('alt')).to.be.true;
expect(imageEl.getAttribute('alt')).to.equal('');
});
it('can receive a `tabindex` without an `href`', async () => {
try {
const el = await fixture<Avatar>(
html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
tabindex="0"
></sp-avatar>
`
);
const el = await fixture<Avatar>(html`
<sp-avatar
label="Shantanu Narayen"
src="https://picsum.photos/500/500"
tabindex="0"
></sp-avatar>
`);
await elementUpdated(el);
const focusEl = el.focusElement;
expect(focusEl).to.exist;
Expand Down
Loading