Skip to content

Commit

Permalink
feat: (CXSPA-1143) - Manual handling of the focus for the facets list (
Browse files Browse the repository at this point in the history
  • Loading branch information
sdrozdsap authored Jun 6, 2024
1 parent 9739878 commit 5c7e38b
Show file tree
Hide file tree
Showing 9 changed files with 190 additions and 9 deletions.
3 changes: 2 additions & 1 deletion projects/assets/src/translations/en/product.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"ariaLabelItemsAvailable": "{{name}}, {{state}} {{count}} item available",
"ariaLabelItemsAvailable_other": "{{name}}, {{state}} {{count}} items available",
"decreaseOptionsVisibility": "Options were hidden from the active group, tab backward to read them or forward for the next group",
"increaseOptionsVisibility": "More options were added to the active group, tab backward to read them or forward for the next group"
"increaseOptionsVisibility": "More options were added to the active group, tab backward to read them or forward for the next group",
"backToResults": "Back To Results"
},
"productSummary": {
"id": "ID",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,14 @@ export interface FeatureTogglesInterface {
* `LoginRegisterComponent`, `ConfigureProductComponent`
*/
a11yUseButtonsForBtnLinks?: boolean;
}

/**
* In `FacetListComponent` dialog view focus will be moved to the first facet
* after single-select facet selection.
* New "Back To Results" button is added
*/
a11yFacetsDialogFocusHandling?: boolean;
}
export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
showDownloadProposalButton: false,
showPromotionsInPDP: false,
Expand Down Expand Up @@ -295,4 +301,5 @@ export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
a11yCloseProductImageBtnFocus: false,
a11yEmptyWishlistHeading: false,
a11yUseButtonsForBtnLinks: false,
a11yFacetsDialogFocusHandling: false,
};
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ if (environment.estimatedDeliveryDate) {
a11yCloseProductImageBtnFocus: true,
a11yEmptyWishlistHeading: true,
a11yUseButtonsForBtnLinks: true,
a11yFacetsDialogFocusHandling: true,
};
return appFeatureToggles;
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,17 @@ <h4>
}
}}"
></cx-facet>

<ng-container *cxFeature="'a11yFacetsDialogFocusHandling'">
<div *ngIf="isDialog" class="cx-facet-list-footer">
<button
#backToResultsBtn
class="btn btn-primary"
(click)="close()"
cxAutoFocus
>
{{ 'productFacetNavigation.backToResults' | cxTranslate }}
</button>
</div>
</ng-container>
</section>
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
import { FeaturesConfig, I18nTestingModule } from '@spartacus/core';
import {
FeatureConfigService,
FeaturesConfig,
I18nTestingModule,
} from '@spartacus/core';
import { EMPTY, of } from 'rxjs';
import { ICON_TYPE } from '../../../../misc/icon/icon.model';
import {
Expand All @@ -19,6 +23,8 @@ import {
} from '../facet.model';
import { FacetService } from '../services/facet.service';
import { FacetListComponent } from './facet-list.component';
import { KeyboardFocusService } from '@spartacus/storefront';
import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive';

@Component({
selector: 'cx-icon',
Expand Down Expand Up @@ -63,6 +69,8 @@ describe('FacetListComponent', () => {
let element: DebugElement;
let service: FacetService;
let renderer: Renderer2;
let focusService: KeyboardFocusService;
let featureConfigService: FeatureConfigService;

beforeEach(
waitForAsync(() => {
Expand All @@ -73,6 +81,7 @@ describe('FacetListComponent', () => {
MockIconComponent,
MockFacetComponent,
MockKeyboadFocusDirective,
MockFeatureDirective,
],
providers: [
{ provide: FacetService, useClass: MockFacetService },
Expand All @@ -98,6 +107,8 @@ describe('FacetListComponent', () => {
component.isDialog = false;
renderer = fixture.componentRef.injector.get<Renderer2>(Renderer2 as any);
service = TestBed.inject(FacetService);
focusService = TestBed.inject(KeyboardFocusService);
featureConfigService = TestBed.inject(FeatureConfigService);
fixture.detectChanges();
});

Expand Down Expand Up @@ -248,4 +259,76 @@ describe('FacetListComponent', () => {
expect(e.nativeElement.classList).toContain('expanded');
});
});

describe('handleDialogFocus', () => {
beforeEach(() => {
spyOn(featureConfigService, 'isEnabled').and.returnValue(true);
});

it('should not set focus if not a dialog', () => {
spyOn(focusService, 'get');
component.isDialog = false;
(component as any).handleDialogFocus([]);
expect(focusService.get).not.toHaveBeenCalled();
});

it('should not set focus if focusKey is not set', () => {
spyOn(focusService, 'clear');
spyOn(focusService, 'get').and.returnValue(null);
component.isDialog = true;
(component as any).handleDialogFocus([]);
expect(focusService.clear).not.toHaveBeenCalled();
});

it('should not change focus if focused facet is found', () => {
spyOn(focusService, 'clear');
spyOn(focusService, 'get').and.returnValue('facet-B');
component.isDialog = true;
(component as any).handleDialogFocus([
{ name: 'facet-A', values: [{ name: 'facet-B' }] },
]);
expect(focusService.clear).not.toHaveBeenCalled();
});

it('should focus on back to results button if no facets are present', () => {
spyOn(focusService, 'clear');
spyOn(focusService, 'get').and.returnValue('facet-B');
component.isDialog = true;
component.backToResultsBtn = {
nativeElement: { focus: jasmine.createSpy('focus') },
} as any;
(component as any).handleDialogFocus([]);
expect(component.backToResultsBtn.nativeElement.focus).toHaveBeenCalled();
expect(focusService.clear).toHaveBeenCalled();
});

it('should set focus to the first available facet if no focused facet is found', () => {
spyOn(focusService, 'set');
spyOn(focusService, 'get').and.returnValue('facet-D');
component.isDialog = true;
(component as any).handleDialogFocus([
{ name: 'facet-A', values: [{ name: 'facet-A' }] },
]);
expect(focusService.set).toHaveBeenCalledWith('facet-A');
});
});

describe('enableFocusHandlingOnFacetListChanges', () => {
it('should handle facet list focus when feature is enabled', () => {
spyOn(<any>component, 'handleDialogFocus');
spyOn(featureConfigService, 'isEnabled').and.returnValue(true);

(component as any).enableFocusHandlingOnFacetListChanges();

expect((component as any).handleDialogFocus).toHaveBeenCalled();
});

it('should add the subscription to the subscriptions collection', () => {
spyOn(featureConfigService, 'isEnabled').and.returnValue(true);
spyOn((component as any).subscriptions, 'add');
(component as any).enableFocusHandlingOnFacetListChanges();

expect((component as any).subscriptions.add).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ import {
EventEmitter,
HostListener,
Input,
OnDestroy,
OnInit,
Optional,
Output,
Renderer2,
ViewChild,
inject,
} from '@angular/core';
import { Facet } from '@spartacus/core';
import { Observable } from 'rxjs';
import { Facet, FeatureConfigService } from '@spartacus/core';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { FocusConfig } from '../../../../../layout/a11y/keyboard-focus/index';
import {
FocusConfig,
KeyboardFocusService,
} from '../../../../../layout/a11y/keyboard-focus/index';
import { ICON_TYPE } from '../../../../misc/icon/icon.model';
import { FacetGroupCollapsedState, FacetList } from '../facet.model';
import { FacetComponent } from '../facet/facet.component';
Expand All @@ -28,8 +36,13 @@ import { FacetService } from '../services/facet.service';
templateUrl: './facet-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FacetListComponent {
export class FacetListComponent implements OnInit, OnDestroy {
protected subscriptions = new Subscription();
private _isDialog: boolean;

@ViewChild('backToResultsBtn')
backToResultsBtn: ElementRef<HTMLButtonElement>;

/**
* Indicates that the facet navigation is rendered in dialog.
*/
Expand Down Expand Up @@ -57,19 +70,30 @@ export class FacetListComponent {
trap: true,
block: true,
focusOnEscape: true,
autofocus: 'cx-facet',
autofocus: 'cx-facet > button',
};

@HostListener('click') handleClick() {
this.close();
}
@Optional() focusService = inject(KeyboardFocusService, { optional: true });
@Optional() featureConfigService = inject(FeatureConfigService, {
optional: true,
});

constructor(
protected facetService: FacetService,
protected elementRef: ElementRef,
protected renderer: Renderer2
) {}

ngOnInit(): void {
// TODO: (CXSPA-7321) - Remove feature flag next major release
if (this.featureConfigService?.isEnabled('a11yFacetsDialogFocusHandling')) {
this.enableFocusHandlingOnFacetListChanges();
}
}

/**
* Toggles the facet group in case it is not expanded.
*/
Expand Down Expand Up @@ -109,4 +133,47 @@ export class FacetListComponent {
block(event?: MouseEvent) {
event?.stopPropagation();
}

protected enableFocusHandlingOnFacetListChanges(): void {
this.subscriptions.add(
this.facetService.facetList$.subscribe((facetList) =>
this.handleDialogFocus(facetList.facets)
)
);
}

protected handleDialogFocus(facets: Facet[]): void {
// Only apply new focus for the dialog view
if (!this.isDialog) {
return;
}

const focusKey = this.focusService?.get();
if (!focusKey) {
return;
}

const focusedFacet = facets.find((facet) =>
facet.values?.some((value) => {
return value.name === focusKey;
})
);
if (focusedFacet) {
return;
}

if (!facets?.length) {
// If there are no facets to display then focus on the "Back To Results" button
this.backToResultsBtn?.nativeElement.focus();
this.focusService?.clear();
return;
}

const firstAvailableFacet = facets[0];
this.focusService?.set(firstAvailableFacet.name);
}

ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { I18nModule } from '@spartacus/core';
import { FeaturesConfigModule, I18nModule } from '@spartacus/core';
import { KeyboardFocusModule } from '../../../../../layout/a11y/keyboard-focus/keyboard-focus.module';
import { IconModule } from '../../../../misc/icon/icon.module';
import { FacetModule } from '../facet/facet.module';
Expand All @@ -19,6 +19,7 @@ import { FacetListComponent } from './facet-list.component';
IconModule,
FacetModule,
KeyboardFocusModule,
FeaturesConfigModule,
],
declarations: [FacetListComponent],
exports: [FacetListComponent],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
[attr.aria-label]="
'productFacetNavigation.filterBy.name' | cxTranslate: { name: facet.name }
"
[cxFocus]="{ key: facet.name }"
>
{{ facet.name }}
<cx-icon class="collapse-icon" [type]="collapseIcon"></cx-icon>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ body.modal-open {
display: none;
}
}

.cx-facet-list-footer {
display: flex;
justify-content: center;
margin-top: 1rem;
margin-bottom: 1rem;
}
}

&.dialog {
Expand Down

0 comments on commit 5c7e38b

Please sign in to comment.