diff --git a/.changeset/fifty-clocks-float.md b/.changeset/fifty-clocks-float.md new file mode 100644 index 00000000000..09a18608565 --- /dev/null +++ b/.changeset/fifty-clocks-float.md @@ -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 diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 649fd9b1a2f..b0ff317e7a8 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -139,4 +139,40 @@ import { Avatar } from '@spectrum-web-components/avatar'; ## Accessibility -The `label` attribute of the `` will be passed into the `` 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 + +``` + +2. Use the `isdecorative` attribute when the avatar is purely decorative and should be hidden from screen readers: + +```html + +``` + +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` diff --git a/packages/avatar/src/Avatar.ts b/packages/avatar/src/Avatar.ts index 2a20ae4876e..199d810ea38 100644 --- a/packages/avatar/src/Avatar.ts +++ b/packages/avatar/src/Avatar.ts @@ -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'; @@ -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; @@ -75,7 +83,8 @@ export class Avatar extends LikeAnchor(Focusable) { const avatar = html` ${ifDefined(this.label `; @@ -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', + } + ); + } } } diff --git a/packages/avatar/stories/avatar.stories.ts b/packages/avatar/stories/avatar.stories.ts index 76f9d8c7ab2..1a80ccc172f 100644 --- a/packages/avatar/stories/avatar.stories.ts +++ b/packages/avatar/stories/avatar.stories.ts @@ -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, }, }; @@ -35,6 +37,7 @@ interface StoryArgs { label?: string; src?: string; size?: AvatarSize; + isdecorative?: boolean; } const Template = ({ @@ -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` + +`; +decorative.args = { isdecorative: true }; diff --git a/packages/avatar/test/avatar.test.ts b/packages/avatar/test/avatar.test.ts index ef7fd312dad..5991562dc67 100644 --- a/packages/avatar/test/avatar.test.ts +++ b/packages/avatar/test/avatar.test.ts @@ -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( - html` - - ` - ) - ); - it('loads accessibly', async () => { - const el = await fixture( - html` + await fixture(html` - ` - ); + `) + ); + it('loads accessibly with label', async () => { + const el = await fixture(html` + + `); 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(html` + + `); + + 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( - html` - - ` + + it('loads with warning when no label is provided', async () => { + const consoleSpy = spy(console, 'warn'); + const el = await fixture(html` + + `); + + 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(html` + + `); + + 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(html` + + `); + await elementUpdated(el); await expect(el).to.be.accessible(); }); it('validates `size`', async () => { - const el = await fixture( - html` - - ` - ); + const el = await fixture(html` + + `); await elementUpdated(el); @@ -81,14 +128,12 @@ describe('Avatar', () => { expect(el.size).to.equal(600); }); it('loads with everything set', async () => { - const el = await fixture( - html` - - ` - ); + const el = await fixture(html` + + `); await elementUpdated(el); expect(el).to.not.be.undefined; @@ -99,30 +144,27 @@ describe('Avatar', () => { expect(imageEl.getAttribute('alt')).to.equal('Shantanu Narayen'); }); it('loads with no label', async () => { - const el = await fixture( - html` - - ` - ); + const el = await fixture(html` + + `); 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( - html` - - ` - ); + const el = await fixture(html` + + `); await elementUpdated(el); const focusEl = el.focusElement; expect(focusEl).to.exist;