Skip to content

Commit

Permalink
fix: reset focus to the main after navigating (#19694)
Browse files Browse the repository at this point in the history
  • Loading branch information
sdrozdsap authored Dec 17, 2024
1 parent 79ad911 commit f232f4d
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,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 @@ -1005,6 +1010,7 @@ export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
a11yNotificationsOnConsentChange: false,
a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: true,
a11yFacetsDialogFocusHandling: true,
a11yResetFocusAfterNavigating: false,
headerLayoutForSmallerViewports: false,
a11yStoreFinderAlerts: false,
a11yStoreFinderLabel: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,17 @@ context('Assisted Service Module', () => {
cy.get('.cx-page-title').then((el) => {
const orderNumber = el.text().match(/\d+/)[0];
cy.log('--> End session');
// const homepage = waitForPage('homepage', 'getHomePage');
cy.get('cx-customer-emulation')
.findByText(/End Session/i)
.click();
// Make sure homepage is visible
cy.wait(`@getHomePage`).its('response.statusCode').should('eq', 200);
cy.get('cx-global-message div').should(
'contain',
'You have successfully signed out.'
);
cy.wait(1000);
asm.startCustomerEmulationWithOrderID(orderNumber, customer);
});
});
Expand Down
1 change: 1 addition & 0 deletions projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ export function startCustomerEmulationWithOrderID(
cy.get('cx-customer-selection form').within(() => {
cy.get('[formcontrolname="searchOrder"]')
.should('not.be.disabled')
.focus()
.type(order);
cy.get('[formcontrolname="searchOrder"]').should('have.value', `${order}`);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,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 @@ -13,6 +13,7 @@ import {
inject,
OnDestroy,
OnInit,
Optional,
ViewChild,
} from '@angular/core';
import {
Expand All @@ -26,11 +27,12 @@ import {
SkipFocusConfig,
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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { distinctUntilChanged } from 'rxjs/operators';
import { DOCUMENT } from '@angular/common';

@Component({
selector: 'cx-storefront',
Expand All @@ -49,6 +51,12 @@ export class StorefrontComponent implements OnInit, OnDestroy {

private featureConfigService = inject(FeatureConfigService);
protected destroyRef = inject(DestroyRef);
@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 @@ -99,10 +107,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 @@ -165,4 +170,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 f232f4d

Please sign in to comment.