Skip to content

Commit b7a2240

Browse files
authored
Add a11y announcements for carousel slide changes (#125)
* Add screen reader slide announcements for carousel navigation * Prevent stale carousel slide announcements
1 parent 366031e commit b7a2240

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/blocks/carousel/__tests__/view.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,9 @@ describe( 'Carousel View Module', () => {
336336

337337
setEmblaOnViewport( viewport, mockEmbla );
338338

339-
const mockContext = createMockContext();
339+
const mockContext = createMockContext( {
340+
selectedIndex: 1,
341+
} );
340342
( mockContext as CarouselContext & { snap?: { index: number } } ).snap = {
341343
index: 0,
342344
};
@@ -694,6 +696,67 @@ describe( 'Carousel View Module', () => {
694696

695697
consoleErrorSpy.mockRestore();
696698
} );
699+
700+
it( 'should update announcement after a manual slide change', () => {
701+
const mockContext = createMockContext( {
702+
announcementPattern: 'Slide {{currentSlide}} of {{totalSlides}}',
703+
selectedIndex: -1,
704+
} );
705+
const { wrapper, viewport } = createMockCarouselDOM();
706+
const listeners: {
707+
select?: () => void;
708+
} = {};
709+
const selectedScrollSnap = jest
710+
.fn()
711+
.mockReturnValueOnce( 0 )
712+
.mockReturnValueOnce( 1 );
713+
const mockEmbla = createMockEmblaInstance( {
714+
selectedScrollSnap,
715+
scrollSnapList: jest.fn( () => [ 0, 1, 2, 3, 4 ] ),
716+
slideNodes: jest.fn( () =>
717+
Array.from( { length: 5 }, () => document.createElement( 'div' ) ),
718+
),
719+
scrollProgress: jest.fn( () => 0.25 ),
720+
} );
721+
const originalIntersectionObserver = window.IntersectionObserver;
722+
723+
mockEmbla.on = jest.fn( ( eventName: string, callback: () => void ) => {
724+
if ( eventName === 'select' ) {
725+
listeners.select = callback;
726+
}
727+
return mockEmbla;
728+
} );
729+
730+
viewport.getBoundingClientRect = jest.fn( () => ( {
731+
width: 100,
732+
height: 0,
733+
top: 0,
734+
right: 0,
735+
bottom: 0,
736+
left: 0,
737+
x: 0,
738+
y: 0,
739+
toJSON: () => ( {} ),
740+
} ) );
741+
742+
( getContext as jest.Mock ).mockReturnValue( mockContext );
743+
( getElement as jest.Mock ).mockReturnValue( { ref: wrapper } );
744+
( EmblaCarousel as unknown as jest.Mock ).mockReturnValue( mockEmbla );
745+
delete ( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver;
746+
747+
try {
748+
storeConfig.callbacks.initCarousel();
749+
750+
mockContext.shouldAnnounce = true;
751+
listeners.select?.();
752+
753+
expect( mockContext.announcement ).toBe( 'Slide 2 of 5' );
754+
expect( mockContext.shouldAnnounce ).toBe( false );
755+
} finally {
756+
( window as Window & { IntersectionObserver?: typeof IntersectionObserver } ).IntersectionObserver =
757+
originalIntersectionObserver;
758+
}
759+
} );
697760
} );
698761
} );
699762
} );

src/blocks/carousel/save.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ export default function Save( {
5252
slideCount: 0,
5353
/* translators: %d: slide number */
5454
ariaLabelPattern: __( 'Go to slide %d', 'rt-carousel' ),
55+
announcement: '',
56+
shouldAnnounce: false,
57+
/* translators: {{currentSlide}}: current slide number, {{totalSlides}}: total slide count. */
58+
announcementPattern: __(
59+
'Slide {{currentSlide}} of {{totalSlides}}',
60+
'rt-carousel',
61+
),
5562
};
5663

5764
const blockProps = useBlockProps.save( {
@@ -71,7 +78,26 @@ export default function Save( {
7178
} as React.CSSProperties,
7279
} );
7380

74-
const innerBlocksProps = useInnerBlocksProps.save( blockProps );
81+
const innerBlocksProps = useInnerBlocksProps.save( blockProps ) as ReturnType<
82+
typeof useInnerBlocksProps.save
83+
> & {
84+
children: React.ReactNode;
85+
};
86+
const { children, ...wrapperProps } = innerBlocksProps;
87+
const announcementLiveRegion = (
88+
<span
89+
className="screen-reader-text"
90+
role="status"
91+
aria-live="polite"
92+
aria-atomic="true"
93+
data-wp-text="context.announcement"
94+
/>
95+
);
7596

76-
return <div { ...innerBlocksProps } />;
97+
return (
98+
<div { ...wrapperProps }>
99+
{ children }
100+
{ announcementLiveRegion }
101+
</div>
102+
);
77103
}

src/blocks/carousel/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export type CarouselContext = {
5757
canScrollNext: boolean;
5858
scrollProgress: number;
5959
ariaLabelPattern: string;
60+
announcement?: string;
61+
announcementPattern?: string;
62+
shouldAnnounce?: boolean;
6063
ref?: HTMLElement | null;
6164
slideCount: number;
6265
initialized?: boolean;

src/blocks/carousel/view.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,42 @@ const getProgress = (): number => {
5656
return Math.max( 0, Math.min( 1, scrollProgress || 0 ) );
5757
};
5858

59+
const getSlideAnnouncement = (
60+
context: CarouselContext,
61+
selectedIndex: number,
62+
slideCount: number,
63+
): string => {
64+
if ( ! slideCount || slideCount <= 1 || ! context.announcementPattern ) {
65+
return '';
66+
}
67+
return context.announcementPattern
68+
.replace( '{{currentSlide}}', ( selectedIndex + 1 ).toString() )
69+
.replace( '{{totalSlides}}', slideCount.toString() );
70+
};
71+
72+
const updateSlideAnnouncement = (
73+
context: CarouselContext,
74+
previousSelectedIndex: number,
75+
): void => {
76+
if ( ! context.shouldAnnounce ) {
77+
return;
78+
}
79+
80+
if ( context.selectedIndex !== previousSelectedIndex ) {
81+
context.announcement = getSlideAnnouncement(
82+
context,
83+
context.selectedIndex,
84+
context.slideCount,
85+
);
86+
}
87+
88+
context.shouldAnnounce = false;
89+
};
90+
91+
const markForAnnouncement = (): void => {
92+
getContext<CarouselContext>().shouldAnnounce = true;
93+
};
94+
5995
store( 'rt-carousel/carousel', {
6096
state: {
6197
get canScrollPrev() {
@@ -72,6 +108,9 @@ store( 'rt-carousel/carousel', {
72108
const element = getElementRef( getElement() );
73109
const embla = getEmblaFromElement( element );
74110
if ( embla ) {
111+
if ( embla.canScrollPrev() ) {
112+
markForAnnouncement();
113+
}
75114
embla.scrollPrev();
76115
} else {
77116
// eslint-disable-next-line no-console
@@ -82,6 +121,9 @@ store( 'rt-carousel/carousel', {
82121
const element = getElementRef( getElement() );
83122
const embla = getEmblaFromElement( element );
84123
if ( embla ) {
124+
if ( embla.canScrollNext() ) {
125+
markForAnnouncement();
126+
}
85127
embla.scrollNext();
86128
} else {
87129
// eslint-disable-next-line no-console
@@ -98,6 +140,9 @@ store( 'rt-carousel/carousel', {
98140
const element = getElementRef( getElement() );
99141
const embla = getEmblaFromElement( element );
100142
if ( embla ) {
143+
if ( snap.index !== context.selectedIndex ) {
144+
markForAnnouncement();
145+
}
101146
embla.scrollTo( snap.index );
102147
}
103148
}
@@ -237,6 +282,7 @@ store( 'rt-carousel/carousel', {
237282
viewport[ EMBLA_KEY ] = embla;
238283

239284
const updateState = () => {
285+
const previousSelectedIndex = context.selectedIndex;
240286
const scrollSnapList = embla.scrollSnapList();
241287
context.initialized = true;
242288
context.canScrollPrev = embla.canScrollPrev();
@@ -249,6 +295,7 @@ store( 'rt-carousel/carousel', {
249295
}
250296
context.scrollProgress = embla.scrollProgress();
251297
context.slideCount = embla.slideNodes().length;
298+
updateSlideAnnouncement( context, previousSelectedIndex );
252299
};
253300

254301
embla.on( 'select', updateState );

0 commit comments

Comments
 (0)