From 26d0ed7868204a3cdfad1d8bce913b4473222fe3 Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Wed, 18 Mar 2026 13:40:56 +0100 Subject: [PATCH 01/10] fix: improve live region behavior for screen readers --- .changeset/wacky-canyons-scream.md | 5 +++++ .../src/components/carousel/carousel.ts | 21 ++++--------------- 2 files changed, 9 insertions(+), 17 deletions(-) create mode 100644 .changeset/wacky-canyons-scream.md diff --git a/.changeset/wacky-canyons-scream.md b/.changeset/wacky-canyons-scream.md new file mode 100644 index 0000000000..ada567f6c1 --- /dev/null +++ b/.changeset/wacky-canyons-scream.md @@ -0,0 +1,5 @@ +--- +'@solid-design-system/components': patch +--- + +Fix `sd-carousel` live region behavior for screen readers. diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index 80cd2f89df..12cf79b970 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -300,16 +300,8 @@ export default class SdCarousel extends SolidElement { this.requestUpdate(); }; - private handleFocus() { - if (this.autoplay) { - this.scrollContainer.setAttribute('aria-live', 'polite'); - } - } - - private handleBlur() { - if (this.autoplay) { - this.scrollContainer.setAttribute('aria-live', 'off'); - } + private get isAutoPlaying(): boolean { + return this.autoplay && !this.pausedAutoplay && !this.autoplayController.paused; } private unblockAutoplay = (e: MouseEvent, button: HTMLButtonElement) => { @@ -620,17 +612,16 @@ export default class SdCarousel extends SolidElement { : `grid overflow-auto overscroll-x-contain grid-flow-col auto-rows-[100%] snap-x snap-mandatory overflow-y-hidden` )}" style="--slides-per-page: ${this.slidesPerPage};" + role="group" aria-busy="${scrollController.scrolling ? 'true' : 'false'}" aria-label="${this.localize.term( 'carouselContainer', Array.from(this.slides).filter(el => !el.hasAttribute('data-clone')).length )}" - aria-live=${this.autoplay ? 'off' : 'polite'} + aria-live=${this.isAutoPlaying ? 'off' : 'polite'} tabindex="0" @keydown=${this.handleKeyDown} @scrollend=${this.handleScrollEnd} - @focus=${this.handleFocus} - @blur=${this.handleBlur} > @@ -649,8 +640,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(); @@ -754,8 +743,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} @click=${nextEnabled ? (e: MouseEvent) => { this.next(); From bad6dc131b7545dddc9aca744fcfd1d84d8511b3 Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Mon, 23 Mar 2026 14:39:34 +0100 Subject: [PATCH 02/10] chore: updated focus and blur functions --- .../src/components/carousel/carousel.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index 12cf79b970..46e0407926 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -300,6 +300,18 @@ export default class SdCarousel extends SolidElement { this.requestUpdate(); }; + private handleFocus() { + if (this.autoplay) { + this.scrollContainer.setAttribute('aria-live', 'polite'); + } + } + + private handleBlur() { + if (this.autoplay) { + this.scrollContainer.setAttribute('aria-live', 'off'); + } + } + private get isAutoPlaying(): boolean { return this.autoplay && !this.pausedAutoplay && !this.autoplayController.paused; } @@ -622,6 +634,8 @@ export default class SdCarousel extends SolidElement { tabindex="0" @keydown=${this.handleKeyDown} @scrollend=${this.handleScrollEnd} + @focus=${this.handleFocus} + @blur=${this.handleBlur} > @@ -639,7 +653,8 @@ 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(); @@ -743,6 +758,8 @@ 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} @click=${nextEnabled ? (e: MouseEvent) => { this.next(); From b308e7c755f8859fead0b72333d4908245f53fa7 Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Mon, 23 Mar 2026 15:06:17 +0100 Subject: [PATCH 03/10] chore: fix the packages --- packages/components/src/components/carousel/carousel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index 46e0407926..a30a4ae677 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -324,7 +324,7 @@ export default class SdCarousel extends SolidElement { }; /** - * Pause the autoplay. + * Pause the autoplay */ public pause() { this.pausedAutoplay = true; From 8a71f78a8d06c7da5a0a0f13317471e7a0586a34 Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Tue, 24 Mar 2026 16:15:54 +0100 Subject: [PATCH 04/10] chore: updated the test and the component --- .../src/components/carousel/carousel.test.ts | 89 +++++++++++++++++++ .../src/components/carousel/carousel.ts | 46 ++++++---- 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/packages/components/src/components/carousel/carousel.test.ts b/packages/components/src/components/carousel/carousel.test.ts index 5292f37f71..76a1a74b77 100644 --- a/packages/components/src/components/carousel/carousel.test.ts +++ b/packages/components/src/components/carousel/carousel.test.ts @@ -579,6 +579,95 @@ describe('', () => { }); }); + describe('live region behavior', () => { + it('should have aria-live="polite" when autoplay is off', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + await el.updateComplete; + + // Assert + expect(el.scrollContainer).to.have.attribute('aria-live', 'polite'); + }); + + it('should have aria-live="off" when autoplay is on', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + await el.updateComplete; + + // Assert + expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + }); + + it('should keep aria-live="off" when autoplay is on but paused', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + await el.updateComplete; + + // Act — pause autoplay + el.autoplayControls.click(); + await el.updateComplete; + + // Assert — should remain off even while paused, to avoid live region flooding + expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + }); + + it('should set aria-live="polite" on focus when autoplay is off', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + await el.updateComplete; + + // Act + el.scrollContainer.dispatchEvent(new Event('focus')); + await el.updateComplete; + + // Assert + expect(el.scrollContainer).to.have.attribute('aria-live', 'polite'); + }); + + it('should not change aria-live on focus when autoplay is on', async () => { + // Arrange + const el = await fixture(html` + + Node 1 + Node 2 + Node 3 + + `); + await el.updateComplete; + + // Act + el.scrollContainer.dispatchEvent(new Event('focus')); + await el.updateComplete; + + // Assert — focus should not flip aria-live back to polite when autoplay is on + expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + }); + }); + 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 a30a4ae677..d34c46fb5d 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -59,7 +59,8 @@ import SolidElement from '../../internal/solid-element.js'; @customElement('sd-carousel') export default class SdCarousel extends SolidElement { @query('[part~="autoplay-controls"]') autoplayControls: HTMLElement; - @query('[part~="navigation-button--previous"]') previousButton: HTMLButtonElement; + @query('[part~="navigation-button--previous"]') + previousButton: HTMLButtonElement; @query('[part~="navigation-button--next"]') nextButton: HTMLButtonElement; @queryAll('[part~="pagination-item"]') paginationItems: HTMLButtonElement[]; @@ -78,7 +79,8 @@ export default class SdCarousel extends SolidElement { @property({ type: Boolean, reflect: true }) fade = false; /** Specifies how many slides should be shown at a given time. */ - @property({ type: Number, attribute: 'slides-per-page', reflect: true }) slidesPerPage = 1; + @property({ type: Number, attribute: 'slides-per-page', reflect: true }) + slidesPerPage = 1; /** * Use `slides-per-move` to set how many slides the carousel advances when scrolling. This is useful when specifying a `slides-per-page` greater than one. By setting `slides-per-move` to the same value as `slides-per-page`, the carousel will advance by one page at a time.
@@ -86,7 +88,8 @@ export default class SdCarousel extends SolidElement { *
  • The number of slides should be divisible by the number of `slides-per-page` to maintain consistent scroll behavior.
  • *
  • Variations between `slides-per-move` and `slides-per-page` can lead to unexpected scrolling behavior. Keep your intended UX in mind when adjusting these values.
  • */ - @property({ type: Number, attribute: 'slides-per-move', reflect: true }) slidesPerMove = 1; + @property({ type: Number, attribute: 'slides-per-move', reflect: true }) + slidesPerMove = 1; @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('.carousel__slides') scrollContainer: HTMLElement; @@ -300,8 +303,22 @@ export default class SdCarousel extends SolidElement { this.requestUpdate(); }; + private handleFocusIn() { + if (this.autoplay && !this.pausedAutoplay) { + this.autoplayController.stop(); + } + } + + private handleFocusOut(event: FocusEvent) { + const relatedTarget = event.relatedTarget as Node | null; + if (this.contains(relatedTarget)) return; + if (this.autoplay && !this.pausedAutoplay) { + this.autoplayController.start(3000); + } + } + private handleFocus() { - if (this.autoplay) { + if (!this.autoplay) { this.scrollContainer.setAttribute('aria-live', 'polite'); } } @@ -312,10 +329,6 @@ export default class SdCarousel extends SolidElement { } } - private get isAutoPlaying(): boolean { - return this.autoplay && !this.pausedAutoplay && !this.autoplayController.paused; - } - private unblockAutoplay = (e: MouseEvent, button: HTMLButtonElement) => { // When the button is clicked with a mouse, blur the button to resume autoplay. if (e.detail) { @@ -324,7 +337,7 @@ export default class SdCarousel extends SolidElement { }; /** - * Pause the autoplay + * Pause the autoplay. */ public pause() { this.pausedAutoplay = true; @@ -561,9 +574,6 @@ export default class SdCarousel extends SolidElement { const slides = this.getSlides(); const slidesWithClones = this.getSlides({ excludeClones: false }); - // Sets the next index without taking into account clones, if any. - // Inconsistencies may arise when scrolling from the last slide if slidesPerMove is not divisible by the slide count. - // This is most apparent with slidesPerPage set to one, but we won't provide a fix as it's not a recommended use case anyways. const newActiveSlide = (index + slides.length) % slides.length; this.activeSlide = newActiveSlide; @@ -571,8 +581,6 @@ export default class SdCarousel extends SolidElement { return; } - // Get the index of the next slide. For looping carousel it adds `slidesPerPage` - // to normalize the starting index in order to ignore the first nth clones. const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length + 1); const nextSlide = slidesWithClones[nextSlideIndex]; @@ -612,7 +620,12 @@ export default class SdCarousel extends SolidElement { const isLtr = this.localize.dir() === 'ltr'; return html` -
    +
    !el.hasAttribute('data-clone')).length )}" - aria-live=${this.isAutoPlaying ? 'off' : 'polite'} + aria-live=${this.autoplay ? 'off' : 'polite'} tabindex="0" @keydown=${this.handleKeyDown} @scrollend=${this.handleScrollEnd} @@ -653,6 +666,7 @@ 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 From bb1c97863fa3872454a89ee65a445634f718612a Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Fri, 27 Mar 2026 15:01:32 +0100 Subject: [PATCH 05/10] chore: fix live region --- .../src/components/carousel/carousel.test.ts | 84 +++++++++++++++---- .../src/components/carousel/carousel.ts | 74 ++++++++-------- packages/components/src/translations/en.ts | 2 +- 3 files changed, 110 insertions(+), 50 deletions(-) diff --git a/packages/components/src/components/carousel/carousel.test.ts b/packages/components/src/components/carousel/carousel.test.ts index 76a1a74b77..b2c27af05a 100644 --- a/packages/components/src/components/carousel/carousel.test.ts +++ b/packages/components/src/components/carousel/carousel.test.ts @@ -579,8 +579,8 @@ describe('', () => { }); }); - describe('live region behavior', () => { - it('should have aria-live="polite" when autoplay is off', async () => { + describe('live region', () => { + it('should have an announcement region with aria-live="polite" when autoplay is off', async () => { // Arrange const el = await fixture(html` @@ -592,10 +592,13 @@ describe('', () => { await el.updateComplete; // Assert - expect(el.scrollContainer).to.have.attribute('aria-live', 'polite'); + 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" when autoplay is on', async () => { + it('should have aria-live="off" on the announcement region when autoplay is on', async () => { // Arrange const el = await fixture(html` @@ -607,10 +610,11 @@ describe('', () => { await el.updateComplete; // Assert - expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!; + expect(announcementRegion).to.have.attribute('aria-live', 'off'); }); - it('should keep aria-live="off" when autoplay is on but paused', async () => { + it('should have aria-live="polite" when autoplay is on but paused', async () => { // Arrange const el = await fixture(html` @@ -621,18 +625,19 @@ describe('', () => { `); await el.updateComplete; - // Act — pause autoplay - el.autoplayControls.click(); + // Act + el.pause(); await el.updateComplete; - // Assert — should remain off even while paused, to avoid live region flooding - expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + // Assert + const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!; + expect(announcementRegion).to.have.attribute('aria-live', 'polite'); }); - it('should set aria-live="polite" on focus when autoplay is off', async () => { + 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 @@ -645,10 +650,11 @@ describe('', () => { await el.updateComplete; // Assert - expect(el.scrollContainer).to.have.attribute('aria-live', 'polite'); + const announcementRegion = el.shadowRoot!.querySelector('.carousel__announcement')!; + expect(announcementRegion).to.have.attribute('aria-live', 'polite'); }); - it('should not change aria-live on focus when autoplay is on', async () => { + it('should have aria-live="off" when autoplay is on and the carousel loses focus', async () => { // Arrange const el = await fixture(html` @@ -662,9 +668,55 @@ describe('', () => { // Act el.scrollContainer.dispatchEvent(new Event('focus')); await el.updateComplete; + el.scrollContainer.dispatchEvent(new Event('blur')); + 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; - // Assert — focus should not flip aria-live back to polite when autoplay is on - expect(el.scrollContainer).to.have.attribute('aria-live', 'off'); + // 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'); }); }); diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index d34c46fb5d..e323abeba5 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -59,8 +59,7 @@ import SolidElement from '../../internal/solid-element.js'; @customElement('sd-carousel') export default class SdCarousel extends SolidElement { @query('[part~="autoplay-controls"]') autoplayControls: HTMLElement; - @query('[part~="navigation-button--previous"]') - previousButton: HTMLButtonElement; + @query('[part~="navigation-button--previous"]') previousButton: HTMLButtonElement; @query('[part~="navigation-button--next"]') nextButton: HTMLButtonElement; @queryAll('[part~="pagination-item"]') paginationItems: HTMLButtonElement[]; @@ -79,8 +78,7 @@ export default class SdCarousel extends SolidElement { @property({ type: Boolean, reflect: true }) fade = false; /** Specifies how many slides should be shown at a given time. */ - @property({ type: Number, attribute: 'slides-per-page', reflect: true }) - slidesPerPage = 1; + @property({ type: Number, attribute: 'slides-per-page', reflect: true }) slidesPerPage = 1; /** * Use `slides-per-move` to set how many slides the carousel advances when scrolling. This is useful when specifying a `slides-per-page` greater than one. By setting `slides-per-move` to the same value as `slides-per-page`, the carousel will advance by one page at a time.
    @@ -88,12 +86,12 @@ export default class SdCarousel extends SolidElement { *
  • The number of slides should be divisible by the number of `slides-per-page` to maintain consistent scroll behavior.
  • *
  • Variations between `slides-per-move` and `slides-per-page` can lead to unexpected scrolling behavior. Keep your intended UX in mind when adjusting these values.
  • */ - @property({ type: Number, attribute: 'slides-per-move', reflect: true }) - slidesPerMove = 1; + @property({ type: Number, attribute: 'slides-per-move', reflect: true }) slidesPerMove = 1; @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 @@ -113,6 +111,12 @@ export default class SdCarousel extends SolidElement { */ @state() pausedAutoplay = false; + /** + * Boolean keeping track of whether the carousel has focus + * @internal + */ + @state() private isFocused = false; + private autoplayController = new AutoplayController(this, () => this.next()); private scrollController = new ScrollController(this); private readonly slides = this.getElementsByTagName('sd-carousel-item'); @@ -303,30 +307,28 @@ export default class SdCarousel extends SolidElement { this.requestUpdate(); }; - private handleFocusIn() { - if (this.autoplay && !this.pausedAutoplay) { - this.autoplayController.stop(); - } - } - - private handleFocusOut(event: FocusEvent) { - const relatedTarget = event.relatedTarget as Node | null; - if (this.contains(relatedTarget)) return; - if (this.autoplay && !this.pausedAutoplay) { - this.autoplayController.start(3000); - } - } - 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) => { @@ -453,6 +455,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 @@ -620,12 +630,12 @@ export default class SdCarousel extends SolidElement { const isLtr = this.localize.dir() === 'ltr'; return html` -
    +
    +
    `Slide ${num}`, + slideNum: (slide, count) => `Slide ${slide} of ${count}`, startDateSelected: 'Start date selected', tagsSelected: 'Options selected', toggleColorFormat: 'Toggle color format', From 9601d4b1ca1ebb778ec1d53a000e7a5c1142b3a1 Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Mon, 30 Mar 2026 15:01:13 +0200 Subject: [PATCH 06/10] chore: focus not preventing auto scroll --- .../src/components/carousel/carousel.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index e323abeba5..095b66a611 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -117,7 +117,9 @@ export default class SdCarousel extends SolidElement { */ @state() private isFocused = false; - private autoplayController = new AutoplayController(this, () => this.next()); + 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 @@ -131,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[]) => { @@ -162,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(); @@ -653,10 +661,7 @@ export default class SdCarousel extends SolidElement { Array.from(this.slides).filter(el => !el.hasAttribute('data-clone')).length )}" tabindex="0" - @keydown=${this.handleKeyDown} @scrollend=${this.handleScrollEnd} - @focus=${this.handleFocus} - @blur=${this.handleBlur} >
    @@ -675,8 +680,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(); @@ -720,7 +723,6 @@ export default class SdCarousel extends SolidElement { this.goToSlide(index * slidesPerMove); this.unblockAutoplay(e, this.paginationItems[index]); }}" - @keydown=${this.handleKeyDown} > { this.next(); From 1f3d8996aa2ffa7c8169c8d14c5c1fea0b6fbc1a Mon Sep 17 00:00:00 2001 From: "Mojsoski, Blagoja (UIS)" Date: Mon, 30 Mar 2026 15:15:57 +0200 Subject: [PATCH 07/10] chore: updated the carousel test --- .../components/src/components/carousel/carousel.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/components/carousel/carousel.test.ts b/packages/components/src/components/carousel/carousel.test.ts index b2c27af05a..92330b0045 100644 --- a/packages/components/src/components/carousel/carousel.test.ts +++ b/packages/components/src/components/carousel/carousel.test.ts @@ -646,7 +646,7 @@ describe('', () => { await el.updateComplete; // Act - el.scrollContainer.dispatchEvent(new Event('focus')); + el.dispatchEvent(new Event('focusin', { bubbles: true })); await el.updateComplete; // Assert @@ -666,9 +666,9 @@ describe('', () => { await el.updateComplete; // Act - el.scrollContainer.dispatchEvent(new Event('focus')); + el.dispatchEvent(new Event('focusin', { bubbles: true })); await el.updateComplete; - el.scrollContainer.dispatchEvent(new Event('blur')); + el.dispatchEvent(new Event('focusout', { bubbles: true })); await el.updateComplete; // Assert From ce6eae14a2191fc35301e486fc967319f2d7d283 Mon Sep 17 00:00:00 2001 From: Blagoja Mojsoski <55854229+balco0110@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:51:26 +0200 Subject: [PATCH 08/10] Update wacky-canyons-scream.md --- .changeset/wacky-canyons-scream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wacky-canyons-scream.md b/.changeset/wacky-canyons-scream.md index ada567f6c1..5ec2f1bf63 100644 --- a/.changeset/wacky-canyons-scream.md +++ b/.changeset/wacky-canyons-scream.md @@ -2,4 +2,4 @@ '@solid-design-system/components': patch --- -Fix `sd-carousel` live region behavior for screen readers. +Fix `sd-carousel` live region behavior for screen readers and focus not preventing auto scroll From 13c8cd2003313e024831b3aabd1f42ca5ab56df0 Mon Sep 17 00:00:00 2001 From: Blagoja Mojsoski <55854229+balco0110@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:25:38 +0200 Subject: [PATCH 09/10] Update .changeset/wacky-canyons-scream.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sérgio Fonseca <42741644+smfonseca@users.noreply.github.com> --- .changeset/wacky-canyons-scream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/wacky-canyons-scream.md b/.changeset/wacky-canyons-scream.md index 5ec2f1bf63..186cf61e66 100644 --- a/.changeset/wacky-canyons-scream.md +++ b/.changeset/wacky-canyons-scream.md @@ -2,4 +2,4 @@ '@solid-design-system/components': patch --- -Fix `sd-carousel` live region behavior for screen readers and focus not preventing auto scroll +Fixed `sd-carousel` live region behavior for screen readers and focus not preventing auto scroll From 594a2b682c9615a892c46b487ba208446ef87a9c Mon Sep 17 00:00:00 2001 From: Blagoja Mojsoski <55854229+balco0110@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:48:06 +0200 Subject: [PATCH 10/10] chore: readd the comments --- packages/components/src/components/carousel/carousel.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/components/src/components/carousel/carousel.ts b/packages/components/src/components/carousel/carousel.ts index 095b66a611..d4d9d538a6 100644 --- a/packages/components/src/components/carousel/carousel.ts +++ b/packages/components/src/components/carousel/carousel.ts @@ -592,6 +592,9 @@ export default class SdCarousel extends SolidElement { const slides = this.getSlides(); const slidesWithClones = this.getSlides({ excludeClones: false }); + // Sets the next index without taking into account clones, if any. + // Inconsistencies may arise when scrolling from the last slide if slidesPerMove is not divisible by the slide count. + // This is most apparent with slidesPerPage set to one, but we won't provide a fix as it's not a recommended use case anyways. const newActiveSlide = (index + slides.length) % slides.length; this.activeSlide = newActiveSlide; @@ -599,6 +602,8 @@ export default class SdCarousel extends SolidElement { return; } + // Get the index of the next slide. For looping carousel it adds `slidesPerPage` + // to normalize the starting index in order to ignore the first nth clones. const nextSlideIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length + 1); const nextSlide = slidesWithClones[nextSlideIndex];