Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(a11y): Arrow keys navigation for carousel #19168

Merged
merged 2 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading