From eae7b4e8e818caa7122d84b6159955c66e6811a1 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Wed, 4 Jun 2025 18:27:10 +0530 Subject: [PATCH 1/3] fix(avatar): should always render alt tag in images --- .changeset/fifty-clocks-float.md | 5 ++ packages/avatar/src/Avatar.ts | 7 +-- packages/avatar/test/avatar.test.ts | 95 +++++++++++++---------------- 3 files changed, 47 insertions(+), 60 deletions(-) create mode 100644 .changeset/fifty-clocks-float.md 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/src/Avatar.ts b/packages/avatar/src/Avatar.ts index 2a20ae4876e..90a003cb5a8 100644 --- a/packages/avatar/src/Avatar.ts +++ b/packages/avatar/src/Avatar.ts @@ -20,7 +20,6 @@ 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'; @@ -73,11 +72,7 @@ export class Avatar extends LikeAnchor(Focusable) { protected override render(): TemplateResult { const avatar = html` - ${ifDefined(this.label + ${this.label `; if (this.href) { return this.renderAnchor({ diff --git a/packages/avatar/test/avatar.test.ts b/packages/avatar/test/avatar.test.ts index ef7fd312dad..bf324a6a26e 100644 --- a/packages/avatar/test/avatar.test.ts +++ b/packages/avatar/test/avatar.test.ts @@ -16,53 +16,45 @@ import { testForLitDevWarnings } from '../../../test/testing-helpers'; describe('Avatar', () => { testForLitDevWarnings( async () => - await fixture( - html` - - ` - ) - ); - it('loads accessibly', async () => { - const el = await fixture( - html` + await fixture(html` - ` - ); + `) + ); + it('loads accessibly', async () => { + const el = await fixture(html` + + `); await elementUpdated(el); await expect(el).to.be.accessible(); }); it('loads accessibly with [href]', async () => { - const el = await fixture( - html` - - ` - ); + 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 +73,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 +89,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; From fba87d3bb6e883ca10d2654db51f90a3e4f09b71 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Wed, 4 Jun 2025 18:35:27 +0530 Subject: [PATCH 2/3] docs: updated a11y docs --- packages/avatar/README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 649fd9b1a2f..6ceabe24002 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -139,4 +139,17 @@ 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: + +- Always includes an `alt` attribute on the image element +- When a `label` is provided, it is used as the `alt` text +- When no `label` is provided, an empty `alt=""` is used to indicate a decorative image +- Supports keyboard navigation when used with `href` or `tabindex` +- Maintains WCAG compliance for non-text content + +### Best Practices + +- Always provide a meaningful `label` when the avatar represents a user or entity +- Use an empty `label` (or omit it) only when the avatar is purely decorative +- When using `href`, ensure the destination is relevant and accessible +- Consider the context when choosing an appropriate `size` From 6f855e074cc05ad9bfc3f4078925092c92eb2849 Mon Sep 17 00:00:00 2001 From: Rajdeep Chandra Date: Wed, 11 Jun 2025 20:01:16 +0530 Subject: [PATCH 3/3] fix(avatar): new API isdecorative --- packages/avatar/README.md | 43 +++++++++++++---- packages/avatar/src/Avatar.ts | 27 ++++++++++- packages/avatar/stories/avatar.stories.ts | 12 +++++ packages/avatar/test/avatar.test.ts | 57 ++++++++++++++++++++++- 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 6ceabe24002..b0ff317e7a8 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -139,17 +139,40 @@ import { Avatar } from '@spectrum-web-components/avatar'; ## Accessibility -The Avatar component is designed to be accessible by default: +The Avatar component is designed to be accessible by default. To ensure proper accessibility: -- Always includes an `alt` attribute on the image element -- When a `label` is provided, it is used as the `alt` text -- When no `label` is provided, an empty `alt=""` is used to indicate a decorative image -- Supports keyboard navigation when used with `href` or `tabindex` -- Maintains WCAG compliance for non-text content +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` when the avatar represents a user or entity -- Use an empty `label` (or omit it) only when the avatar is purely decorative -- When using `href`, ensure the destination is relevant and accessible -- Consider the context when choosing an appropriate `size` +- Always provide a meaningful `label` diff --git a/packages/avatar/src/Avatar.ts b/packages/avatar/src/Avatar.ts index 90a003cb5a8..199d810ea38 100644 --- a/packages/avatar/src/Avatar.ts +++ b/packages/avatar/src/Avatar.ts @@ -22,6 +22,7 @@ import { } from '@spectrum-web-components/base/src/decorators.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'; @@ -47,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; @@ -72,7 +81,12 @@ export class Avatar extends LikeAnchor(Focusable) { protected override render(): TemplateResult { const avatar = html` - ${this.label + ${ifDefined(this.isdecorative `; if (this.href) { return this.renderAnchor({ @@ -89,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 bf324a6a26e..5991562dc67 100644 --- a/packages/avatar/test/avatar.test.ts +++ b/packages/avatar/test/avatar.test.ts @@ -12,6 +12,7 @@ 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( @@ -23,7 +24,7 @@ describe('Avatar', () => { > `) ); - it('loads accessibly', async () => { + 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 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`