Skip to content
Merged
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/wacky-canyons-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solid-design-system/components': patch
---

Fixed `sd-carousel` live region behavior for screen readers and focus not preventing auto scroll
141 changes: 141 additions & 0 deletions packages/components/src/components/carousel/carousel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,147 @@ describe('<sd-carousel>', () => {
});
});

describe('live region', () => {
it('should have an announcement region with aria-live="polite" when autoplay is off', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel>
<sd-carousel-item>Node 1</sd-carousel-item>
<sd-carousel-item>Node 2</sd-carousel-item>
<sd-carousel-item>Node 3</sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion).to.exist;
expect(announcementRegion).to.have.attribute('aria-live', 'polite');
expect(announcementRegion).to.have.attribute('aria-atomic', 'true');
});

it('should have aria-live="off" on the announcement region when autoplay is on', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel autoplay>
<sd-carousel-item>Node 1</sd-carousel-item>
<sd-carousel-item>Node 2</sd-carousel-item>
<sd-carousel-item>Node 3</sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion).to.have.attribute('aria-live', 'off');
});

it('should have aria-live="polite" when autoplay is on but paused', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel autoplay>
<sd-carousel-item>Node 1</sd-carousel-item>
<sd-carousel-item>Node 2</sd-carousel-item>
<sd-carousel-item>Node 3</sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Act
el.pause();
await el.updateComplete;

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion).to.have.attribute('aria-live', 'polite');
});

it('should have aria-live="polite" when autoplay is on and the carousel is focused', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel autoplay>
<sd-carousel-item>Node 1</sd-carousel-item>
<sd-carousel-item>Node 2</sd-carousel-item>
<sd-carousel-item>Node 3</sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Act
el.dispatchEvent(new Event('focusin', { bubbles: true }));
await el.updateComplete;

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion).to.have.attribute('aria-live', 'polite');
});

it('should have aria-live="off" when autoplay is on and the carousel loses focus', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel autoplay>
<sd-carousel-item>Node 1</sd-carousel-item>
<sd-carousel-item>Node 2</sd-carousel-item>
<sd-carousel-item>Node 3</sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Act
el.dispatchEvent(new Event('focusin', { bubbles: true }));
await el.updateComplete;
el.dispatchEvent(new Event('focusout', { bubbles: true }));
await el.updateComplete;

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion).to.have.attribute('aria-live', 'off');
});

it('should announce slide text and position when navigating', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel>
<sd-carousel-item>Node 1</sd-carousel-item>
<sd-carousel-item>Node 2</sd-carousel-item>
<sd-carousel-item>Node 3</sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Act
el.goToSlide(1);
await el.updateComplete;
await aTimeout(0); // wait for requestAnimationFrame

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion.textContent).to.include('Node 2');
expect(announcementRegion.textContent).to.match(/2.*3|slide 2/i);
});

it('should announce image alt text when navigating', async () => {
// Arrange
const el = await fixture<SdCarousel>(html`
<sd-carousel>
<sd-carousel-item><img alt="A dog on the beach" /></sd-carousel-item>
<sd-carousel-item><img alt="A cat on a chair" /></sd-carousel-item>
<sd-carousel-item><img alt="A bird in a tree" /></sd-carousel-item>
</sd-carousel>
`);
await el.updateComplete;

// Act
el.goToSlide(1);
await el.updateComplete;
await aTimeout(0);

// Assert
const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!;
expect(announcementRegion.textContent).to.include('A cat on a chair');
});
});

describe('when scrolling', () => {
it('should update aria-busy attribute', async () => {
// Arrange
Expand Down
61 changes: 46 additions & 15 deletions packages/components/src/components/carousel/carousel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export default class SdCarousel extends SolidElement {
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.carousel__slides') scrollContainer: HTMLElement;
@query('.carousel__pagination') paginationContainer: HTMLElement;
@query('.carousel__announcement') announcementRegion: HTMLElement;

/**
* The index of the active slide
Expand All @@ -110,7 +111,15 @@ export default class SdCarousel extends SolidElement {
*/
@state() pausedAutoplay = false;

private autoplayController = new AutoplayController(this, () => this.next());
/**
* Boolean keeping track of whether the carousel has focus
* @internal
*/
@state() private isFocused = false;

private autoplayController = new AutoplayController(this, () => {
if (!this.isFocused) this.next();
});
private scrollController = new ScrollController(this);
private readonly slides = this.getElementsByTagName('sd-carousel-item');
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
Expand All @@ -124,6 +133,9 @@ export default class SdCarousel extends SolidElement {
connectedCallback(): void {
super.connectedCallback();
['click', 'keydown'].forEach(event => this.addEventListener(event, this.handleUserInteraction));
this.addEventListener('focusin', this.handleFocus);
this.addEventListener('focusout', this.handleBlur);
this.addEventListener('keydown', this.handleKeyDown);

const intersectionObserver = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
Expand Down Expand Up @@ -155,6 +167,9 @@ export default class SdCarousel extends SolidElement {
this.intersectionObserver.disconnect();
this.mutationObserver.disconnect();
['click', 'keydown'].forEach(event => this.removeEventListener(event, this.handleUserInteraction));
this.removeEventListener('focusin', this.handleFocus);
this.removeEventListener('focusout', this.handleBlur);
this.removeEventListener('keydown', this.handleKeyDown);

if (this.fade) {
this.fadeController.disable();
Expand Down Expand Up @@ -301,15 +316,27 @@ export default class SdCarousel extends SolidElement {
};

private handleFocus() {
if (this.autoplay) {
this.scrollContainer.setAttribute('aria-live', 'polite');
}
this.isFocused = true;
}

private handleBlur() {
if (this.autoplay) {
this.scrollContainer.setAttribute('aria-live', 'off');
this.isFocused = false;
}

private getSlideText(slide: SdCarouselItem): string {
const parts: string[] = [];
const walker = document.createTreeWalker(slide, NodeFilter.SHOW_ALL);
let node: Node | null = walker.nextNode();
while (node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim();
if (text) parts.push(text);
} else if (node instanceof HTMLImageElement && node.alt) {
parts.push(node.alt);
}
node = walker.nextNode();
}
return parts.join(' ');
}

private unblockAutoplay = (e: MouseEvent, button: HTMLButtonElement) => {
Expand Down Expand Up @@ -436,6 +463,14 @@ export default class SdCarousel extends SolidElement {
slide: slides[this.activeSlide]
}
});

if (this.announcementRegion) {
const text = this.getSlideText(slides[this.activeSlide]);
const position = this.localize.term('slideNum', this.activeSlide + 1, slides.length);
requestAnimationFrame(() => {
this.announcementRegion.textContent = text ? `${text} ${position}` : position;
});
}
}

// Check page count after all other updates
Expand Down Expand Up @@ -609,6 +644,11 @@ export default class SdCarousel extends SolidElement {

return html`
<div part="base" class=${cx(`carousel h-full w-full`)}>
<div
class="carousel__announcement sr-only"
aria-live=${!this.autoplay || this.pausedAutoplay || this.isFocused ? 'polite' : 'off'}
aria-atomic="true"
></div>
<div
id="scroll-container"
part="scroll-container"
Expand All @@ -625,12 +665,8 @@ export default class SdCarousel extends SolidElement {
'carouselContainer',
Array.from(this.slides).filter(el => !el.hasAttribute('data-clone')).length
)}"
aria-live=${this.autoplay ? 'off' : 'polite'}
tabindex="0"
@keydown=${this.handleKeyDown}
@scrollend=${this.handleScrollEnd}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
>
<slot></slot>
</div>
Expand All @@ -649,8 +685,6 @@ export default class SdCarousel extends SolidElement {
aria-label="${this.localize.term('previousSlide')}"
aria-controls="scroll-container"
aria-disabled="${prevEnabled ? 'false' : 'true'}"
@focus=${this.handleFocus}
@blur=${this.handleBlur}
@click=${prevEnabled
? (e: MouseEvent) => {
this.previous();
Expand Down Expand Up @@ -694,7 +728,6 @@ export default class SdCarousel extends SolidElement {
this.goToSlide(index * slidesPerMove);
this.unblockAutoplay(e, this.paginationItems[index]);
}}"
@keydown=${this.handleKeyDown}
>
<span
class=${cx(
Expand Down Expand Up @@ -754,8 +787,6 @@ export default class SdCarousel extends SolidElement {
aria-label="${this.localize.term('nextSlide')}"
aria-controls="scroll-container"
aria-disabled="${nextEnabled ? 'false' : 'true'}"
@focus=${this.handleFocus}
@blur=${this.handleBlur}
Comment thread
auroraVasconcelos marked this conversation as resolved.
@click=${nextEnabled
? (e: MouseEvent) => {
this.next();
Expand Down
2 changes: 1 addition & 1 deletion packages/components/src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const translation: Translation = {
showLess: 'Show less',
showMore: 'Show more',
showPassword: 'Show password',
slideNum: num => `Slide ${num}`,
slideNum: (slide, count) => `Slide ${slide} of ${count}`,
startDateSelected: 'Start date selected',
tagsSelected: 'Options selected',
toggleColorFormat: 'Toggle color format',
Expand Down
Loading