Skip to content

Commit

Permalink
feat(a11y): Arrow keys navigation for carousel
Browse files Browse the repository at this point in the history
  • Loading branch information
sdrozdsap committed Sep 13, 2024
1 parent 6a15452 commit 2b9b398
Show file tree
Hide file tree
Showing 13 changed files with 364 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@

<ng-template #thumb let-item="item">
<cx-media
*cxFeature="'a11yCarouselArrowKeysNavigation'"
cxFocusableCarouselItem
[container]="item.container"
tabindex="0"
(focus)="openImage(item.container)"
[class.is-active]="isActive(item.container) | async"
format="product"
>
</cx-media>
<cx-media
*cxFeature="'!a11yCarouselArrowKeysNavigation'"
[container]="item.container"
tabindex="0"
(focus)="openImage(item.container)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CurrentProductService } from '@spartacus/storefront';
import { EMPTY, Observable, of } from 'rxjs';
import { take } from 'rxjs/operators';
import { ProductImageZoomProductImagesComponent } from './product-image-zoom-product-images.component';
import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive';

const firstImage = {
zoom: {
Expand Down Expand Up @@ -111,6 +112,7 @@ describe('ProductImagesComponent', () => {
MockMediaComponent,
MockCarouselComponent,
MockProductImageZoomTriggerComponent,
MockFeatureDirective,
],
providers: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@

<ng-template #thumb let-item="item">
<cx-media
*cxFeature="'a11yCarouselArrowKeysNavigation'"
cxFocusableCarouselItem
[container]="item.container"
tabindex="0"
(focus)="openImage(item.container)"
[class.is-active]="isActive(item.container) | async"
>
</cx-media>
<cx-media
*cxFeature="'!a11yCarouselArrowKeysNavigation'"
[container]="item.container"
tabindex="0"
(focus)="openImage(item.container)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Component, Input, EventEmitter } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ProductImageZoomThumbnailsComponent } from './product-image-zoom-thumbnails.component';
import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive';

const firstImage = {
zoom: {
Expand Down Expand Up @@ -49,6 +50,7 @@ describe('ProductImageZoomThumbnailsComponent', () => {
declarations: [
ProductImageZoomThumbnailsComponent,
MockCarouselComponent,
MockFeatureDirective,
],
}).compileComponents();
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,12 @@ export interface FeatureTogglesInterface {
*/
a11yUseButtonsForBtnLinks?: boolean;

/**
* `ProductImageZoomProductImagesComponent`, `ProductImageZoomThumbnailsComponent` - enable
* arrow keys navigation for the carousel
*/
a11yCarouselArrowKeysNavigation?: boolean;

/**
* `AnonymousConsentDialogComponent` - after consent was given/withdrawn the notification
* will be displayed
Expand Down Expand Up @@ -601,6 +607,7 @@ export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
a11yEmptyWishlistHeading: false,
a11yScreenReaderBloatFix: false,
a11yUseButtonsForBtnLinks: false,
a11yCarouselArrowKeysNavigation: false,
a11yNotificationsOnConsentChange: false,
a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: false,
a11yFacetsDialogFocusHandling: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ if (environment.cpq) {
a11yEmptyWishlistHeading: true,
a11yScreenReaderBloatFix: true,
a11yUseButtonsForBtnLinks: true,
a11yCarouselArrowKeysNavigation: true,
a11yNotificationsOnConsentChange: true,
a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields:
true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ <h2 *ngIf="title">{{ title }}</h2>
*ngIf="item | async as data"
class="item"
[class.active]="i === activeSlide"
(keydown)="onItemKeydown($event, size)"
>
<ng-container
*ngTemplateOutlet="template; context: { item: data }"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core';
import { Component, Input, TemplateRef } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
Expand Down Expand Up @@ -48,7 +48,7 @@ describe('Carousel Component', () => {
let service: CarouselService;

let templateFixture: ComponentFixture<MockTemplateComponent>;
let template;
let template: TemplateRef<any>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule, I18nTestingModule],
Expand Down Expand Up @@ -419,4 +419,160 @@ describe('Carousel Component', () => {
expect(component.getSlideNumber(5, 27)).toBe(6);
});
});

describe('keyboard navigation', () => {
let nativeElement: HTMLElement;
const sizeMock = 4;
beforeEach(() => {
component.template = template;
nativeElement = fixture.nativeElement;

for (let i = 0; i < 10; i++) {
const element = document.createElement('div');
element.setAttribute('cxFocusableCarouselItem', '');
element.addEventListener(
'keydown',
(e) => component.onItemKeydown(e, sizeMock),
{ once: true }
);
nativeElement.appendChild(element);
}
fixture.detectChanges();
});

describe('onItemKeydown', () => {
it('should call focusNextPrevItem with +1 when ArrowRight is pressed', () => {
spyOn(<any>component, 'focusNextPrevItem');
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'ArrowRight',
});

const targetElement = nativeElement.querySelector(
'[cxFocusableCarouselItem]'
);
targetElement?.dispatchEvent(keyboardEvent);

expect(component['focusNextPrevItem']).toHaveBeenCalledWith(
targetElement,
1,
sizeMock
);
});

it('should call focusNextPrevItem with -1 when ArrowLeft is pressed', () => {
spyOn(<any>component, 'focusNextPrevItem');
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'ArrowLeft',
});

const targetElement = nativeElement.querySelector(
'[cxFocusableCarouselItem]'
);
targetElement?.dispatchEvent(keyboardEvent);

expect(component['focusNextPrevItem']).toHaveBeenCalledWith(
targetElement,
-1,
sizeMock
);
});

it('should not handle keydown events other than ArrowRight or ArrowLeft', () => {
spyOn(<any>component, 'focusNextPrevItem');
const keyboardEvent = new KeyboardEvent('keydown', { key: 'ArrowUp' });

const targetElement = nativeElement.querySelector(
'[cxFocusableCarouselItem]'
);
targetElement?.dispatchEvent(keyboardEvent);

expect(component['focusNextPrevItem']).not.toHaveBeenCalled();
});
});

describe('focusNextPrevItem', () => {
let focusableElements: NodeListOf<HTMLElement>;
beforeEach(() => {
nativeElement = fixture.nativeElement;
component.activeSlide = 0;
fixture.detectChanges();
focusableElements = nativeElement.querySelectorAll(
'[cxFocusableCarouselItem]'
);
});

it('should focus the next item within the current slide', () => {
const initialIndex = 0;
const targetIndex = 1;
spyOn(focusableElements[targetIndex], 'focus');

component['focusNextPrevItem'](
focusableElements[initialIndex],
1,
sizeMock
);

expect(focusableElements[targetIndex].focus).toHaveBeenCalled();
expect(component.activeSlide).toBe(0);
});

it('should update the active slide and focus next item when crossing boundary', () => {
const initialIndex = 3;
const targetIndex = 4;

spyOn(focusableElements[targetIndex], 'addEventListener');

component['focusNextPrevItem'](
focusableElements[initialIndex],
1,
sizeMock
);

expect(
focusableElements[targetIndex].addEventListener
).toHaveBeenCalledWith('transitionend', jasmine.any(Function), {
once: true,
});
expect(component.activeSlide).toBe(4);
});

it('should handle transitionend event to focus the target element', (done) => {
const initialIndex = 3;
const targetIndex = 4;
const focusableElements = nativeElement.querySelectorAll(
'[cxFocusableCarouselItem]'
);
const targetElement = focusableElements[targetIndex] as HTMLElement;
spyOn(targetElement, 'focus');

component['focusNextPrevItem'](
focusableElements[initialIndex],
1,
sizeMock
);

const event = new Event('transitionend');
targetElement.dispatchEvent(event);

setTimeout(() => {
expect(targetElement.focus).toHaveBeenCalled();
done();
}, 100);
});

it('should not change focus if attempting to navigate out of bounds', () => {
const initialIndex = 0;
spyOn(focusableElements[initialIndex], 'focus');

component['focusNextPrevItem'](
focusableElements[initialIndex],
-1,
sizeMock
);

expect(focusableElements[initialIndex].focus).not.toHaveBeenCalled();
expect(component.activeSlide).toBe(0);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,62 @@ export class CarouselComponent implements OnInit {
.pipe(tap(() => (this.activeSlide = 0)));
}

onItemKeydown(event: KeyboardEvent, size: number): void {
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault();
this.focusNextPrevItem(
event.target,
event.key === 'ArrowRight' ? 1 : -1,
size
);
}
}

/**
* Focuses the next or previous item in the carousel based on keyboard navigation.
*
* This method determines the next focusable carousel item, identified by the
* `cxFocusableCarouselItem` directive, based on the current focus and the direction
* given. It adjusts the carousel's active slide if the next focusable item is
* outside the currently visible items.
*
* @param currentItem - The currently focused carousel item.
* @param direction - The navigation direction (1 for right, -1 for left).
* @param size - The number of items per slide, used to determine slide change is needed
*/
protected focusNextPrevItem(
currentItem: EventTarget | null,
direction: number,
size: number
): void {
const focusableElements = this.el.nativeElement.querySelectorAll(
'[cxFocusableCarouselItem]'
);
const currentIndex = Array.from(focusableElements).indexOf(currentItem);
const nextIndex = currentIndex + direction;
if (nextIndex < 0 || nextIndex >= focusableElements.length) {
return;
}

const targetElement = focusableElements[nextIndex] as HTMLElement;
const shouldChangeSlide =
nextIndex < this.activeSlide || nextIndex >= this.activeSlide + size;
if (shouldChangeSlide) {
this.activeSlide = nextIndex - (nextIndex % size);
// After changing slides carousel items has CSS transition,
// which prevents them from being focused
targetElement.addEventListener(
'transitionend',
() => {
targetElement.focus();
},
{ once: true }
);
} else {
targetElement.focus();
}
}

getSlideNumber(size: number, currentIndex: number): number {
const normalizedCurrentIndex = currentIndex + 1;
return Math.ceil(normalizedCurrentIndex / size);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { FeaturesConfigModule, I18nModule, UrlModule } from '@spartacus/core';
import { IconModule } from '../../../cms-components/misc/icon/index';
import { MediaModule } from '../media/media.module';
import { CarouselComponent } from './carousel.component';
import { FocusableCarouselItemDirective } from './focusable-carousel-item/focusable-carousel-item.directive';

@NgModule({
imports: [
Expand All @@ -22,7 +23,7 @@ import { CarouselComponent } from './carousel.component';
I18nModule,
FeaturesConfigModule,
],
declarations: [CarouselComponent],
exports: [CarouselComponent],
declarations: [CarouselComponent, FocusableCarouselItemDirective],
exports: [CarouselComponent, FocusableCarouselItemDirective],
})
export class CarouselModule {}
Loading

0 comments on commit 2b9b398

Please sign in to comment.