Skip to content

Commit 245d586

Browse files
authored
fix(carousel): improved keyboard navigation and SR support #188 (#351)
1 parent 3eb40c4 commit 245d586

5 files changed

Lines changed: 825 additions & 32 deletions

File tree

tedi/components/content/carousel/carousel-content/carousel-content.component.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
>
77
@for (idx of renderedIndices(); track $index) {
88
<div
9+
#slide
910
class="tedi-carousel__slide"
10-
[attr.role]="$index === renderedActiveIndex() ? 'group' : 'presentation'"
11+
[attr.role]="isSlideVisible($index) ? 'group' : 'presentation'"
1112
aria-roledescription="slide"
1213
[attr.aria-label]="
1314
translationService.track('carousel.slide', idx + 1, slides().length)()
1415
"
1516
[attr.aria-current]="$index === renderedActiveIndex() ? 'true' : null"
16-
[attr.aria-hidden]="$index === renderedActiveIndex() ? null : 'true'"
17+
[attr.aria-hidden]="isSlideVisible($index) ? null : 'true'"
18+
[attr.tabindex]="isSlideVisible($index) ? '-1' : null"
1719
[style.flex]="slideFlex()"
1820
>
1921
<ng-container *ngTemplateOutlet="slides()[idx].template"></ng-container>

tedi/components/content/carousel/carousel-content/carousel-content.component.scss

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
position: relative;
33
width: 100%;
44
overflow: hidden;
5+
overscroll-behavior: contain;
56
touch-action: pan-y;
67
cursor: grab;
78

@@ -33,5 +34,16 @@
3334

3435
.tedi-carousel__slide {
3536
user-select: none;
37+
scroll-snap-align: none;
38+
scroll-margin: 0;
3639
-webkit-user-drag: none;
40+
41+
&:focus {
42+
outline: none;
43+
}
44+
45+
&:focus-visible {
46+
outline: var(--borders-02) solid var(--tedi-primary-500);
47+
outline-offset: calc(-1 * var(--borders-03));
48+
}
3749
}

tedi/components/content/carousel/carousel-content/carousel-content.component.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import {
77
contentChildren,
88
signal,
99
viewChild,
10+
viewChildren,
1011
AfterViewInit,
1112
OnDestroy,
1213
input,
1314
inject,
1415
HostListener,
1516
} from "@angular/core";
1617
import { NgTemplateOutlet } from "@angular/common";
18+
import { LiveAnnouncer } from "@angular/cdk/a11y";
1719
import { CarouselSlideDirective } from "../carousel-slide.directive";
1820
import {
1921
breakpointInput,
@@ -61,8 +63,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
6163
readonly translationService = inject(TediTranslationService);
6264
private readonly breakpointService = inject(BreakpointService);
6365
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
66+
private readonly liveAnnouncer = inject(LiveAnnouncer);
6467

6568
readonly track = viewChild.required<ElementRef<HTMLDivElement>>("track");
69+
readonly slideElements = viewChildren<ElementRef<HTMLDivElement>>("slide");
6670
readonly slides = contentChildren(CarouselSlideDirective);
6771

6872
readonly trackIndex = signal(0);
@@ -147,6 +151,16 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
147151
return this.trackIndex() - this.windowBase() + this.buffer();
148152
});
149153

154+
/**
155+
* Checks if a slide at the given rendered index is currently visible in the viewport.
156+
* Used to determine which slides should be accessible to screen readers.
157+
*/
158+
isSlideVisible(renderedIndex: number): boolean {
159+
const activeIndex = this.renderedActiveIndex();
160+
const slidesPerView = Math.ceil(this.currentSlidesPerView());
161+
return renderedIndex >= activeIndex && renderedIndex < activeIndex + slidesPerView;
162+
}
163+
150164
readonly renderedIndices = computed(() => {
151165
const slidesCount = this.slides().length;
152166

@@ -211,12 +225,20 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
211225

212226
locked = false;
213227
dragging = false;
228+
private pendingFocus = false;
214229
private startX = 0;
215230
private startIndex = 0;
216231
private ro?: ResizeObserver;
217232
private wheelTimeout?: ReturnType<typeof setTimeout>;
218233
private scrollDelta = 0;
219234

235+
@HostListener("scroll")
236+
onScroll() {
237+
// Prevent any scroll triggered by focus (e.g., VoiceOver navigation)
238+
this.host.nativeElement.scrollLeft = 0;
239+
this.host.nativeElement.scrollTop = 0;
240+
}
241+
220242
@HostListener("wheel", ["$event"])
221243
onWheel(event: WheelEvent) {
222244
const slidesCount = this.slides().length;
@@ -395,6 +417,7 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
395417
this.animate.set(true);
396418
this.trackIndex.update((i) => i + 1);
397419
this.lockNavigation();
420+
this.announceSlideChange();
398421
}
399422

400423
prev(): void {
@@ -405,9 +428,10 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
405428
this.animate.set(true);
406429
this.trackIndex.update((i) => i - 1);
407430
this.lockNavigation();
431+
this.announceSlideChange();
408432
}
409433

410-
goToIndex(index: number) {
434+
goToIndex(index: number, options?: { focusSlide?: boolean }) {
411435
const slidesCount = this.slides().length;
412436

413437
if (!slidesCount || this.locked) {
@@ -419,6 +443,27 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
419443
const delta = normalized - current;
420444
this.animate.set(true);
421445
this.trackIndex.update((i) => i + delta);
446+
447+
if (options?.focusSlide) {
448+
// Focus after transition completes so DOM positions are stable
449+
this.pendingFocus = true;
450+
} else {
451+
this.announceSlideChange();
452+
}
453+
}
454+
455+
/**
456+
* Focuses the currently active slide for screen reader users.
457+
* Uses preventScroll to avoid breaking carousel layout.
458+
*/
459+
focusActiveSlide(): void {
460+
setTimeout(() => {
461+
const activeIndex = this.renderedActiveIndex();
462+
const slideElement = this.slideElements()[activeIndex];
463+
if (slideElement) {
464+
slideElement.nativeElement.focus({ preventScroll: true });
465+
}
466+
});
422467
}
423468

424469
onTransitionEnd(e: TransitionEvent) {
@@ -432,10 +477,33 @@ export class CarouselContentComponent implements AfterViewInit, OnDestroy {
432477

433478
this.animate.set(false);
434479
this.windowBase.set(Math.floor(this.trackIndex()));
480+
481+
if (this.pendingFocus) {
482+
this.pendingFocus = false;
483+
this.focusActiveSlide();
484+
}
435485
}
436486

437487
lockNavigation() {
438488
this.locked = true;
439489
setTimeout(() => (this.locked = false), this.transitionMs());
440490
}
491+
492+
/**
493+
* Announces the current slide position to screen readers via LiveAnnouncer.
494+
* Called after navigation to inform users of the slide change.
495+
*/
496+
announceSlideChange(): void {
497+
setTimeout(() => {
498+
const slideNumber = this.slideIndex() + 1;
499+
const totalSlides = this.slides().length;
500+
const message = this.translationService.translate(
501+
"carousel.slide",
502+
slideNumber,
503+
totalSlides
504+
);
505+
506+
this.liveAnnouncer.announce(message, "polite");
507+
}, 100);
508+
}
441509
}

tedi/components/content/carousel/carousel-indicators/carousel-indicators.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,6 @@ export class CarouselIndicatorsComponent {
5757
}
5858

5959
handleIndicatorClick(index: number) {
60-
this.carousel.carouselContent().goToIndex(index);
60+
this.carousel.carouselContent().goToIndex(index, { focusSlide: true });
6161
}
6262
}

0 commit comments

Comments
 (0)