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 Aug 27, 2024
1 parent dbb1949 commit 42187c2
Show file tree
Hide file tree
Showing 10 changed files with 324 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 @@ -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
Expand Up @@ -395,6 +395,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 @@ -546,6 +552,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 @@ -342,6 +342,7 @@ if (environment.cpq) {
a11yEmptyWishlistHeading: true,
a11yScreenReaderBloatFix: true,
a11yUseButtonsForBtnLinks: true,
a11yCarouselArrowKeysNavigation: false,
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,50 @@ 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
);
}
}

private 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 {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FocusableCarouselItemDirective } from './focusable-carousel-item.directive';
import { LoggerService } from '@spartacus/core';
import { ElementRef } from '@angular/core';
const createSpy = jasmine.createSpy;

describe('FocusableCarouselItemDirective', () => {
let directive: FocusableCarouselItemDirective;
let mockLogger: LoggerService;
let mockElementRef: ElementRef;

beforeEach(() => {
mockLogger = {
warn: createSpy(),
} as unknown as LoggerService;

mockElementRef = {
nativeElement: document.createElement('div'),
};

directive = new FocusableCarouselItemDirective(mockLogger, mockElementRef);
});

it('should warn if element cannot receive focus in dev mode', () => {
const element = document.createElement('div');
mockElementRef.nativeElement = element;

directive = new FocusableCarouselItemDirective(mockLogger, mockElementRef);

expect(mockLogger.warn).toHaveBeenCalled();
});

it('should detect if element is focusable', () => {
mockElementRef.nativeElement = document.createElement('input');
directive = new FocusableCarouselItemDirective(mockLogger, mockElementRef);

expect(directive['canElementReceiveFocus']()).toBeTruthy();
});

it('should detect if non-focusable element with non-negative tabindex is focusable', () => {
const element = document.createElement('div');
element.setAttribute('tabindex', '0');
mockElementRef.nativeElement = element;

directive = new FocusableCarouselItemDirective(mockLogger, mockElementRef);

expect(directive['canElementReceiveFocus']()).toBeTruthy();
});

it('should detect disabled element as not focusable', () => {
const element = document.createElement('input');
element.setAttribute('disabled', '');
mockElementRef.nativeElement = element;

directive = new FocusableCarouselItemDirective(mockLogger, mockElementRef);

expect(directive['canElementReceiveFocus']()).toBeFalsy();
});
});
Loading

0 comments on commit 42187c2

Please sign in to comment.