Skip to content
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
Comment on lines +163 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- 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
- When a `label` is provided, it is used as the `alt` text for the image
- When the avatar has an `href`, the `label` serves as link text
- 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Always provide a `label` for avatars that represent users or have meaningful content
- Always provide a `label` for avatars that represent users, have meaningful content, or act as links

- Use `isdecorative` for purely decorative avatars
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Use `isdecorative` for purely decorative avatars
- Use `isdecorative` for purely avatars that do not have an `href` and are purely decorative

- 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: two-word property names are usually camelCase and then surfaced as kebab case attributes, as in isDecorative and is-decorative

I would recommend naming this either with the property and attribute name decorative.


@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'}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the avatar is keyboard navigable it should not be hidden from a screenreader.

Suggested change
aria-hidden=${this.isdecorative ? 'true' : 'false'}
aria-hidden=${this.isdecorative && !this.href ? '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',
}
);
}
Comment on lines +106 to +116
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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',
}
);
}
if (!this.label) {
if(this.href) {
// Log a warning if avatar is a link but has no label
window.__swc.warn(
this,
`<${this.localName}> is has a link but no label. To fix this issue, provide a label.`,
'https://opensource.adobe.com/spectrum-web-components/components/avatar/#accessibility',
{
type: 'accessibility',
}
);
} else if(!this.isdecorative) {
// Log a warning if neither label nor isdecorative is set
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