@@ -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" ;
1617import { NgTemplateOutlet } from "@angular/common" ;
18+ import { LiveAnnouncer } from "@angular/cdk/a11y" ;
1719import { CarouselSlideDirective } from "../carousel-slide.directive" ;
1820import {
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}
0 commit comments