diff --git a/.changeset/wacky-canyons-scream.md b/.changeset/wacky-canyons-scream.md new file mode 100644 index 0000000000..186cf61e66 --- /dev/null +++ b/.changeset/wacky-canyons-scream.md @@ -0,0 +1,5 @@ +--- +'@solid-design-system/components': patch +--- + +Fixed `sd-carousel` live region behavior for screen readers and focus not preventing auto scroll diff --git a/packages/components/src/components/carousel/carousel.test.ts b/packages/components/src/components/carousel/carousel.test.ts index 5292f37f71..92330b0045 100644 --- a/packages/components/src/components/carousel/carousel.test.ts +++ b/packages/components/src/components/carousel/carousel.test.ts @@ -579,6 +579,147 @@ describe('', () => { }); }); + describe('live region', () => { + it('should have an announcement region with aria-live="polite" when autoplay is off', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + Node 1 + Node 2 + Node 3 + + `); + 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(html` + + A dog on the beach + A cat on a chair + A bird in a tree + + `); + 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 diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index 80cd2f89df..d4d9d538a6 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -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 @@ -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 @@ -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[]) => { @@ -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(); @@ -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) => { @@ -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 @@ -609,6 +644,11 @@ export default class SdCarousel extends SolidElement { return html`
+
!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} >
@@ -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(); @@ -694,7 +728,6 @@ export default class SdCarousel extends SolidElement { this.goToSlide(index * slidesPerMove); this.unblockAutoplay(e, this.paginationItems[index]); }}" - @keydown=${this.handleKeyDown} > { this.next(); diff --git a/packages/components/src/translations/en.ts b/packages/components/src/translations/en.ts index b12810bf7b..b229d8f913 100644 --- a/packages/components/src/translations/en.ts +++ b/packages/components/src/translations/en.ts @@ -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',