Skip to content

Commit

Permalink
feat: skip focus to main after navigation
Browse files Browse the repository at this point in the history
  • Loading branch information
sdrozdsap committed Dec 5, 2024
1 parent a94c8c8 commit 33a5455
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,11 @@ export interface FeatureTogglesInterface {
*/
a11yFacetsDialogFocusHandling?: boolean;

/**
* Resets the focus after navigating to a new page.
*/
a11yResetFocusAfterNavigating?: boolean;

/**
* `StorefrontComponent`: Prevents header links from wrapping on smaller screen sizes.
* Enables support for increased letter-spacing up to 0.12em for header layout
Expand Down Expand Up @@ -916,6 +921,7 @@ export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
a11yNotificationsOnConsentChange: false,
a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: false,
a11yFacetsDialogFocusHandling: true,
a11yResetFocusAfterNavigating: false,
headerLayoutForSmallerViewports: false,
a11yStoreFinderAlerts: false,
a11yFormErrorMuteIcon: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ if (environment.cpq) {
a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields:
true,
a11yFacetsDialogFocusHandling: true,
a11yResetFocusAfterNavigating: true,
headerLayoutForSmallerViewports: true,
a11yStoreFinderAlerts: true,
a11yFormErrorMuteIcon: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,16 @@ export class SkipLinkService {
}
}

scrollToTarget(skipLink: SkipLink): void {
scrollToTarget(scrollTo: string | SkipLink): void {
const skipLink =
typeof scrollTo === 'string'
? this.findSkipLinkByKey(scrollTo)
: scrollTo;

if (!skipLink) {
return;
}

const target =
skipLink.target instanceof HTMLElement
? skipLink.target
Expand All @@ -77,6 +86,10 @@ export class SkipLinkService {
}
}

protected findSkipLinkByKey(key: string): SkipLink | undefined {
return this.skipLinks$.value.find((skipLink) => skipLink.key === key);
}

protected getSkipLinkIndexInArray(key: string): number {
let index: number =
this.config.skipLinks?.findIndex((skipLink) => skipLink.key === key) ?? 0;
Expand Down
88 changes: 87 additions & 1 deletion projects/storefrontlib/layout/main/storefront.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Component, DebugElement, Directive, Input } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { RoutingService } from '@spartacus/core';
import { FeatureConfigService, RoutingService } from '@spartacus/core';
import { EMPTY, Observable, of } from 'rxjs';
import { OutletDirective } from '../../cms-structure';
import { MockFeatureDirective } from '../../shared/test/mock-feature-directive';
import { HamburgerMenuService } from '../header/hamburger-menu/hamburger-menu.service';
import { StorefrontComponent } from './storefront.component';
import { SkipLinkService } from '../a11y/skip-link/index';

@Component({
selector: 'cx-header',
Expand Down Expand Up @@ -52,6 +53,7 @@ class MockPageLayoutComponent {}

class MockHamburgerMenuService {
toggle(_forceCollapse?: boolean): void {}
isExpanded = of(false);
}

@Directive({
Expand All @@ -61,11 +63,25 @@ class MockOutletDirective implements Partial<OutletDirective> {
@Input() cxOutlet: string;
}

class MockSkipLinkService implements Partial<SkipLinkService> {
getSkipLinks() {
return of([
{
key: 'cx-main',
target: document.createElement('div'),
i18nKey: 'skipLink.main',
},
]);
}
scrollToTarget(): void {}
}

describe('StorefrontComponent', () => {
let component: StorefrontComponent;
let fixture: ComponentFixture<StorefrontComponent>;
let el: DebugElement;
let routingService: RoutingService;
let skipLinkService: SkipLinkService;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
Expand All @@ -90,6 +106,16 @@ describe('StorefrontComponent', () => {
provide: HamburgerMenuService,
useClass: MockHamburgerMenuService,
},
{
provide: SkipLinkService,
useClass: MockSkipLinkService,
},
{
provide: FeatureConfigService,
useValue: {
isEnabled: () => true,
},
},
],
}).compileComponents();
}));
Expand All @@ -99,6 +125,7 @@ describe('StorefrontComponent', () => {
component = fixture.componentInstance;
el = fixture.debugElement;
routingService = TestBed.inject(RoutingService);
skipLinkService = TestBed.inject(SkipLinkService);
});

it('should create', () => {
Expand Down Expand Up @@ -147,4 +174,63 @@ describe('StorefrontComponent', () => {
component.collapseMenuIfClickOutside(mockEvent);
expect(component.collapseMenu).not.toHaveBeenCalled();
});

describe('onNavigation', () => {
it('should set navigation flags correctly when navigation starts', () => {
component['onNavigation'](true);
expect(component.startNavigating).toBe(true);
expect(component.stopNavigating).toBe(false);
});

it('should set navigation flags correctly when navigation ends', () => {
component['onNavigation'](false);
expect(component.startNavigating).toBe(false);
expect(component.stopNavigating).toBe(true);
});

it('should call skipLinkService.scrollToTarget when navigation ends and document has active element', () => {
spyOn(skipLinkService, 'scrollToTarget');
spyOn(component['featureConfigService'], 'isEnabled').and.returnValue(
true
);

const mockDocument = {
activeElement: document.createElement('button'),
body: document.createElement('body'),
};
component['document'] = mockDocument as any;

component['onNavigation'](false);

expect(skipLinkService.scrollToTarget).toHaveBeenCalledWith('cx-main');
});

it('should not call skipLinkService.scrollToTarget when navigation ends and focus is on body', () => {
spyOn(skipLinkService, 'scrollToTarget');
spyOn(component['featureConfigService'], 'isEnabled').and.returnValue(
true
);
const body = document.createElement('body');
const mockDocument = {
activeElement: body,
body,
};
component['document'] = mockDocument as any;

component['onNavigation'](false);

expect(skipLinkService.scrollToTarget).not.toHaveBeenCalled();
});

it('should not call skipLinkService.scrollToTarget when feature is disabled', () => {
spyOn(skipLinkService, 'scrollToTarget');
spyOn(component['featureConfigService'], 'isEnabled').and.returnValue(
false
);

component['onNavigation'](false);

expect(skipLinkService.scrollToTarget).not.toHaveBeenCalled();
});
});
});
29 changes: 24 additions & 5 deletions projects/storefrontlib/layout/main/storefront.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
HostListener,
OnDestroy,
OnInit,
Optional,
ViewChild,
inject,
} from '@angular/core';
Expand All @@ -24,9 +25,10 @@ import {
FocusConfig,
KeyboardFocusService,
} from '../a11y/keyboard-focus/index';
import { SkipLinkComponent } from '../a11y/skip-link/index';
import { SkipLinkComponent, SkipLinkService } from '../a11y/skip-link/index';
import { HamburgerMenuService } from '../header/hamburger-menu/hamburger-menu.service';
import { StorefrontOutlets } from './storefront-outlets.model';
import { DOCUMENT } from '@angular/common';

@Component({
selector: 'cx-storefront',
Expand All @@ -39,6 +41,12 @@ export class StorefrontComponent implements OnInit, OnDestroy {
readonly StorefrontOutlets = StorefrontOutlets;

private featureConfigService = inject(FeatureConfigService);
@Optional() protected document = inject(DOCUMENT, {
optional: true,
});
@Optional() protected skipLinkService = inject(SkipLinkService, {
optional: true,
});

@HostBinding('class.start-navigating') startNavigating: boolean;
@HostBinding('class.stop-navigating') stopNavigating: boolean;
Expand Down Expand Up @@ -88,10 +96,7 @@ export class StorefrontComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.navigateSubscription = this.routingService
.isNavigating()
.subscribe((val) => {
this.startNavigating = val === true;
this.stopNavigating = val === false;
});
.subscribe((val) => this.onNavigation(val));
if (
this.featureConfigService.isEnabled(
'a11yMobileFocusOnFirstNavigationItem'
Expand Down Expand Up @@ -138,4 +143,18 @@ export class StorefrontComponent implements OnInit, OnDestroy {
this.navigateSubscription.unsubscribe();
}
}

protected onNavigation(isNavigating: boolean): void {
this.startNavigating = isNavigating === true;
this.stopNavigating = isNavigating === false;

// After clicking a link the focus should move to the first available item in the main content area.
if (
this.featureConfigService.isEnabled('a11yResetFocusAfterNavigating') &&
this.stopNavigating &&
this.document?.activeElement !== this.document?.body
) {
this.skipLinkService?.scrollToTarget('cx-main');
}
}
}

0 comments on commit 33a5455

Please sign in to comment.