From e59839eae1d148ec0c779aa3048967249de33f75 Mon Sep 17 00:00:00 2001 From: LarisaStar <61147963+Larisa-Staroverova@users.noreply.github.com> Date: Mon, 22 Apr 2024 07:09:08 +0200 Subject: [PATCH 01/18] docs: JSDocs for product configurator feature toggle --- .../feature-toggles/config/feature-toggles.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts index b07f59e0001..f328422d3ec 100644 --- a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -31,6 +31,16 @@ export interface FeatureTogglesInterface { */ storeFrontLibCardParagraphTruncated?: boolean; + /** + * In `ConfiguratorAttributeDropDownComponent`, `ConfiguratorAttributeSingleSelectionImageComponent` + * and in 'ConfiguratorAttributeMultiSelectionImageComponent' some HTML changes were done + * to render read-only attribute with images and a long description at the value level accordingly. + * + * In `cx-configurator-price`, `cx-configurator-show-more`,`cx-configurator-attribute-drop-down`, + * `cx-configurator-attribute-selection-image`, `cx-configurator-attribute-single-selection-bundle-dropdown`, + * `cx-configurator-attribute-type` and `cx-configurator-form-group` some styling changes were done + * to render read-only attribute with images and a long description at the value level accordingly. + */ productConfiguratorAttributeTypesV2?: boolean; /** From a8ed98edf17d57383dfb815d064251c43508d29e Mon Sep 17 00:00:00 2001 From: Radhep Sabapathipillai <34665674+RadhepS@users.noreply.github.com> Date: Mon, 22 Apr 2024 10:53:01 -0400 Subject: [PATCH 02/18] fix: update major version in SCSS (#18755) fix: update major version in SCSS closes: https://jira.tools.sap/browse/CXSPA-6735 --- .../storefrontstyles/scss/_versioning.scss | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/projects/storefrontstyles/scss/_versioning.scss b/projects/storefrontstyles/scss/_versioning.scss index a1d6020108b..581b8bccc92 100644 --- a/projects/storefrontstyles/scss/_versioning.scss +++ b/projects/storefrontstyles/scss/_versioning.scss @@ -34,7 +34,7 @@ $_fullVersion: ( // The _global_ major version. Any (left over) styles from previous stable versions // are processed to the final CSS. -$_majorVersion: 6; +$_majorVersion: 2211; // The `$styleVersion` can be used by customers to explicitly opt-in to breaking style // changes till the given style version. It must contain a floating number, such as `2.1`. @@ -66,7 +66,7 @@ $useLatestStyles: false !default; $from: getVersion($from); // We never create content that is part of future releases, this should not really happen. - @if (compareVersion($from, $_fullVersion) <= 0) { + @if (compareVersion($from, $_fullVersion) <=0) { @if ( (isStableVersion($from, $to) and isValidVersion($from, $to)) or addBreakingChange($from, $to) @@ -83,11 +83,13 @@ $useLatestStyles: false !default; $string: toString($value); $i: str-index($string, '.'); - @if $i != null { + + @if $i !=null { $major: toNumber(str-slice($string, 1, $i - 1)); $remaining: str-slice($string, $i + 1); $d: str-index($remaining, '.'); - @if $d != null { + + @if $d !=null { $minor: toNumber(str-slice($remaining, 1, $d - 1)); $patch: toNumber(str-slice($remaining, $d + 1)); } @else { @@ -112,9 +114,11 @@ $useLatestStyles: false !default; $firstItem: toString(nth($value, 1)); $secondItem: toString(nth($value, 2)); $firstDot: str-index($firstItem, '.'); - @if $firstDot != null { + + @if $firstDot !=null { $secondDot: str-index($secondItem, '.'); - @if $secondDot != null { + + @if $secondDot !=null { @return $firstItem + '.' + str-slice($secondItem, $secondDot + 1); } @else { @return $firstItem; @@ -130,6 +134,7 @@ $useLatestStyles: false !default; @function toNumber($string) { $strings: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9'; $result: 0; + @for $i from 1 through str-length($string) { $character: str-slice($string, $i, $i); $number: index($strings, $character) - 1; @@ -143,9 +148,11 @@ $useLatestStyles: false !default; @if type-of($v1) != 'map' { $v1: getVersion($v1); } + @if type-of($v2) != 'map' { $v2: getVersion($v2); } + $v1Major: map-get($v1, major); $v1Minor: map-get($v1, minor); $v1Patch: map-get($v1, patch); @@ -155,9 +162,11 @@ $useLatestStyles: false !default; $v2Patch: map-get($v2, patch); $compareMajor: compareNumber($v1Major, $v2Major); - @if ($compareMajor == 0) { + + @if ($compareMajor ==0) { $compareMinor: compareNumber($v1Minor, $v2Minor); - @if ($compareMinor == 0) { + + @if ($compareMinor ==0) { @return compareNumber($v1Patch, $v2Patch); } @else { @return $compareMinor; @@ -183,33 +192,34 @@ $useLatestStyles: false !default; // - from 1.1 to 1.2 is not valid for version 1 // - from 1.1 to 1.2 is not valid for version 2 @function isStableVersion($from, $to: 0) { - @if ($to == 0) { - @return compareVersion($from, $_majorVersion) <= 0; + @if ($to ==0) { + @return compareVersion($from, $_majorVersion) <=0; } @else { - @return compareVersion($from, $_majorVersion) <= 0 and + @return compareVersion($from, $_majorVersion) <=0 and compareVersion($to, $_majorVersion) >=0; } } // Indicates that the style rule should be created for the given opt-in rules. @function addBreakingChange($from, $to) { - @if (useBreakingChanges() == false) { + @if (useBreakingChanges() ==false) { @return false; } + @return isValidVersion($from, $to); } // Indicates that the given from / to versions are valid. @function isValidVersion($from, $to: 0) { - @if ($to == 0) { + @if ($to ==0) { // ensure that we opt-in the from version @return $useLatestStyles or - ($useLatestStyles == false and compareVersion($from, $styleVersion) <= 0); + ($useLatestStyles ==false and compareVersion($from, $styleVersion) <=0); } @else { // ensure that we opt-in the from/to version @return ($useLatestStyles and compareVersion($to, $_fullVersion) >=0) or ( - $useLatestStyles == false and compareVersion($from, $styleVersion) <=0 + $useLatestStyles ==false and compareVersion($from, $styleVersion) <=0 and compareVersion($to, $styleVersion) >=0 ); } @@ -217,5 +227,5 @@ $useLatestStyles: false !default; // Indicates if breaking changes are requested. @function useBreakingChanges() { - @return $useLatestStyles or compareVersion($styleVersion, $_majorVersion) > 0; + @return $useLatestStyles or compareVersion($styleVersion, $_majorVersion) >0; } From f6dfd88ce434c78eca8efc8d4e20e927dc4a7eaf Mon Sep 17 00:00:00 2001 From: Caine Rotherham Date: Tue, 23 Apr 2024 09:28:40 +0200 Subject: [PATCH 03/18] fix: Fix AppRouting configurations not being active when using install script (#18717) Closes: https://jira.tools.sap/browse/CXSPA-6158 --- .../config/default-on-navigate-config.ts | 1 + .../router/config/on-navigate-config.ts | 7 +++++ .../router/on-navigate.service.spec.ts | 25 +++++++++++++++++- .../router/on-navigate.service.ts | 26 +++++++++++++++++-- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/projects/storefrontlib/router/config/default-on-navigate-config.ts b/projects/storefrontlib/router/config/default-on-navigate-config.ts index 39fe6f5e776..b62db4d6c30 100644 --- a/projects/storefrontlib/router/config/default-on-navigate-config.ts +++ b/projects/storefrontlib/router/config/default-on-navigate-config.ts @@ -11,5 +11,6 @@ export const defaultOnNavigateConfig: OnNavigateConfig = { active: true, ignoreQueryString: false, ignoreRoutes: [], + selectedHostElement: 'cx-storefront', }, }; diff --git a/projects/storefrontlib/router/config/on-navigate-config.ts b/projects/storefrontlib/router/config/on-navigate-config.ts index aa1cd589ff9..0b0766ec568 100644 --- a/projects/storefrontlib/router/config/on-navigate-config.ts +++ b/projects/storefrontlib/router/config/on-navigate-config.ts @@ -16,6 +16,13 @@ export abstract class OnNavigateConfig { active?: boolean; ignoreQueryString?: boolean; ignoreRoutes?: string[]; + /** + * When set, finds the element with the tag name matching this string + * to return focus to on navigation. + * + * Uses hostComponent when unset. + */ + selectedHostElement?: string; }; } diff --git a/projects/storefrontlib/router/on-navigate.service.spec.ts b/projects/storefrontlib/router/on-navigate.service.spec.ts index 5c32e41cfdb..c78750cd539 100644 --- a/projects/storefrontlib/router/on-navigate.service.spec.ts +++ b/projects/storefrontlib/router/on-navigate.service.spec.ts @@ -28,9 +28,18 @@ const mockComponentRef = { location: { nativeElement: { ...MockComponent, focus: (): void => {} } }, }; +const mockElement = { + focus: (): void => {}, +}; + class MockInjector implements Partial { get(_token: any): ApplicationRef { - return { components: [mockComponentRef] } as any; + return { + components: [mockComponentRef], + getElementsByTagName: (el: string) => { + return el ? [mockElement] : undefined; + }, + } as any; } } @@ -218,4 +227,18 @@ describe('OnNavigateService', () => { expect(mockComponentRef.location.nativeElement.focus).toHaveBeenCalled(); }); }); + + describe('selectedHostElement', () => { + beforeEach(() => { + config.enableResetViewOnNavigate.selectedHostElement = 'cx-storefront'; + }); + + it('should call focus on storefront component when selectedHostElement is set', () => { + const ref: any = service.selectedHostElement; + spyOn(ref, 'focus').and.callThrough(); + service.setResetViewOnNavigate(true); + emitPairScrollEvent(null); + expect(ref.focus).toHaveBeenCalled(); + }); + }); }); diff --git a/projects/storefrontlib/router/on-navigate.service.ts b/projects/storefrontlib/router/on-navigate.service.ts index 3a146f24192..3843892fdef 100644 --- a/projects/storefrontlib/router/on-navigate.service.ts +++ b/projects/storefrontlib/router/on-navigate.service.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ViewportScroller } from '@angular/common'; +import { DOCUMENT, ViewportScroller } from '@angular/common'; import { ApplicationRef, ComponentRef, @@ -37,6 +37,16 @@ export class OnNavigateService { return this.injector.get(ApplicationRef)?.components?.[0]; } + get selectedHostElement(): HTMLElement | undefined { + const toSelect = + this.config?.enableResetViewOnNavigate?.selectedHostElement; + return toSelect + ? ( + this.injector.get(DOCUMENT)?.getElementsByTagName?.(toSelect)?.[0] + ) + : undefined; + } + constructor( protected config: OnNavigateConfig, protected router: Router, @@ -93,11 +103,23 @@ export class OnNavigateService { this.scrollToPosition(currentRoute, position); } - this.hostComponent?.location?.nativeElement.focus(); + this.focusOnHostElement(); }); } } + /** + * Focus on selectedHostElement if set in config. + * Otherwise, focuses on hostComponent. + */ + protected focusOnHostElement() { + if (this.selectedHostElement) { + this.selectedHostElement?.focus(); + } else { + this.hostComponent?.location?.nativeElement.focus(); + } + } + /** * Scrolls to a specified position or anchor based on the current route and configuration. * @param currentRoute The current route containing scroll information. From 575a124b00348fa0ecd04aa5deaa5cd9c83a0ecb Mon Sep 17 00:00:00 2001 From: sdrozdsap <163305268+sdrozdsap@users.noreply.github.com> Date: Tue, 23 Apr 2024 09:42:19 +0100 Subject: [PATCH 04/18] fix: (CXSPA-1104) - Provide more meaningful text for order status (#18751) --- feature-libs/order/assets/translations/en/order.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature-libs/order/assets/translations/en/order.json b/feature-libs/order/assets/translations/en/order.json index 9e9024b2e3a..1a41cf0c822 100644 --- a/feature-libs/order/assets/translations/en/order.json +++ b/feature-libs/order/assets/translations/en/order.json @@ -34,8 +34,8 @@ "deliveryStatus_PICKUP_COMPLETE": "Pickup Complete", "deliveryStatus_DELIVERY_COMPLETED": "Delivery Complete", "deliveryStatus_PAYMENT_NOT_CAPTURED": "Payment Issue", - "deliveryStatus_IN_PROCESS": "In Process", - "deliveryStatus_READY": "In Process", + "deliveryStatus_IN_PROCESS": "Order Processing", + "deliveryStatus_READY": "Order Processing", "deliveryStatus_DELIVERY_REJECTED": "Delivery Rejected", "deliveryStatus_SHIPPED": "Shipped", "deliveryStatus_TAX_NOT_COMMITTED": "Tax Issue", From 217ddb80c6389654eb1f2eb74aaae732f0975039 Mon Sep 17 00:00:00 2001 From: PioBar <72926984+Pio-Bar@users.noreply.github.com> Date: Tue, 23 Apr 2024 12:26:19 +0200 Subject: [PATCH 05/18] fix: (CXSPA-4344) - Quantity update on enter press (#18713) Co-authored-by: Piotr Bartkowiak --- .../add-to-cart/add-to-cart.component.ts | 28 ++++++++++++++++++- .../item-counter/item-counter.component.html | 6 ++-- .../item-counter.component.spec.ts | 14 ++++++++++ .../item-counter/item-counter.component.ts | 11 +++++++- .../item-counter/item-counter.module.ts | 3 +- 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.ts b/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.ts index 859f1f99cde..ab786e879da 100644 --- a/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.ts +++ b/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.ts @@ -9,10 +9,12 @@ import { ChangeDetectorRef, Component, ComponentRef, + HostListener, Input, OnDestroy, OnInit, Optional, + inject, } from '@angular/core'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { @@ -24,8 +26,9 @@ import { import { CmsAddToCartComponent, EventService, - isNotNullable, + FeatureConfigService, Product, + isNotNullable, } from '@spartacus/core'; import { CmsComponentData, @@ -74,6 +77,29 @@ export class AddToCartComponent implements OnInit, OnDestroy { iconTypes = ICON_TYPE; + @Optional() featureConfigService = inject(FeatureConfigService, { + optional: true, + }); + + /** + * We disable the dialog launch on quantity input, + * as it causes an unexpected change of context. + * The expectation is only for the quantity to get updated in the Qty field. + */ + @HostListener('document:keydown', ['$event']) + handleKeyboardEvent(event: KeyboardEvent) { + // TODO: (CXSPA-6034) Remove Feature flag next major release + if (!this.featureConfigService?.isEnabled('a11yQuantityOrderTabbing')) { + return; + } + const eventTarget = event.target as HTMLElement; + const isQuantityInput = + eventTarget.ariaLabel === 'Quantity' && eventTarget.tagName === 'INPUT'; + if (event.key === 'Enter' && isQuantityInput) { + event.preventDefault(); + } + } + constructor( protected currentProductService: CurrentProductService, protected cd: ChangeDetectorRef, diff --git a/projects/storefrontlib/shared/components/item-counter/item-counter.component.html b/projects/storefrontlib/shared/components/item-counter/item-counter.component.html index fcfedc59576..6942ec15586 100644 --- a/projects/storefrontlib/shared/components/item-counter/item-counter.component.html +++ b/projects/storefrontlib/shared/components/item-counter/item-counter.component.html @@ -3,11 +3,11 @@ (click)="decrement()" [disabled]="control.disabled || control.value <= min" [tabindex]="control.disabled || control.value <= min ? -1 : 0" + [cxFocus]="{ key: 'decrement' }" attr.aria-label="{{ 'itemCounter.removeOne' | cxTranslate }}" > - - diff --git a/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.spec.ts b/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.spec.ts index 2cb05beb90f..817d122a34e 100644 --- a/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.spec.ts +++ b/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.spec.ts @@ -1,10 +1,20 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { I18nTestingModule } from '@spartacus/core'; +import { + FeatureConfigService, + I18nTestingModule, + RoutingService, +} from '@spartacus/core'; import { ToggleLinkCellComponent } from '@spartacus/organization/administration/components'; import { IconModule, OutletContextData } from '@spartacus/storefront'; import { UrlTestingModule } from 'projects/core/src/routing/configurable-routes/url-translation/testing/url-testing.module'; +import { Subject } from 'rxjs'; import { UnitTreeService } from '../../services/unit-tree.service'; import createSpy = jasmine.createSpy; @@ -23,6 +33,14 @@ class MockUnitTreeService implements Partial { toggle = createSpy('toggle'); } +class MockRoutingService implements Partial { + go = () => Promise.resolve(true); +} + +class MockFeatureConfigService { + isEnabled = () => true; +} + describe('ToggleLinkCellComponent', () => { let component: ToggleLinkCellComponent; let unitTreeService: UnitTreeService; @@ -46,6 +64,14 @@ describe('ToggleLinkCellComponent', () => { provide: UnitTreeService, useClass: MockUnitTreeService, }, + { + provide: RoutingService, + useClass: MockRoutingService, + }, + { + provide: FeatureConfigService, + useClass: MockFeatureConfigService, + }, ], }).compileComponents(); }); @@ -61,10 +87,6 @@ describe('ToggleLinkCellComponent', () => { expect(component).toBeTruthy(); }); - it('should return 0 for tabIndex', () => { - expect(component.tabIndex).toEqual(0); - }); - it('should render tabindex = 0 by default', () => { const el: HTMLElement = fixture.debugElement.query(By.css('a')).nativeNode; expect(el.innerText).toEqual('my name (1)'); @@ -78,4 +100,145 @@ describe('ToggleLinkCellComponent', () => { el.click(); expect(unitTreeService.toggle).toHaveBeenCalledWith(mockContext); }); + + describe('a11y', () => { + const mockElement1 = document.createElement('a'); + const mockElement2 = document.createElement('a'); + const mockSiblingElements = [mockElement1, mockElement2]; + const mockSpaceEvent = new KeyboardEvent('keydown', { key: ' ' }); + const mockArrowDownEvent = new KeyboardEvent('keydown', { + key: 'ArrowDown', + }); + const mockArrowUpEvent = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + const mockArrowRightEvent = new KeyboardEvent('keydown', { + key: 'ArrowRight', + }); + const mockArrowLeftEvent = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + }); + + it('should enable keyboard controls', () => { + const mockTableElement = { + querySelectorAll: jasmine + .createSpy('querySelectorAll') + .and.returnValue(mockSiblingElements), + }; + component['elementRef'] = { + nativeElement: { + closest: jasmine + .createSpy('closest') + .and.returnValue(mockTableElement), + }, + }; + spyOn(component, 'onSpace').and.stub(); + spyOn(component, 'onArrowDown').and.stub(); + spyOn(component, 'onArrowUp').and.stub(); + spyOn(component, 'onArrowRight').and.stub(); + spyOn(component, 'onArrowLeft').and.stub(); + + component.onKeydown(mockSpaceEvent); + expect(component.onSpace).toHaveBeenCalled(); + component.onKeydown(mockArrowDownEvent); + expect(component.onArrowDown).toHaveBeenCalled(); + component.onKeydown(mockArrowUpEvent); + expect(component.onArrowUp).toHaveBeenCalled(); + component.onKeydown(mockArrowRightEvent); + expect(component.onArrowRight).toHaveBeenCalled(); + component.onKeydown(mockArrowLeftEvent); + expect(component.onArrowLeft).toHaveBeenCalled(); + }); + + it('should make active item the only focusable item and navigate', () => { + Object.defineProperty(mockSpaceEvent, 'target', { + value: mockElement1, + }); + spyOn(mockSpaceEvent, 'preventDefault'); + spyOn(component, 'restoreFocus'); + + component.onSpace(mockSpaceEvent, mockSiblingElements); + + expect(mockSpaceEvent.preventDefault).toHaveBeenCalled(); + expect(mockElement1.tabIndex).toEqual(0); + expect(mockElement2.tabIndex).toEqual(-1); + fixture.whenStable().then(() => { + expect(component.restoreFocus).toHaveBeenCalled(); + }); + }); + + it('should focus next link on ArrowDown', () => { + const currentSelectedIndex = 0; + spyOn(mockArrowDownEvent, 'preventDefault'); + spyOn(mockElement2, 'focus'); + + component.onArrowDown( + mockArrowDownEvent, + currentSelectedIndex, + mockSiblingElements + ); + + expect(mockArrowDownEvent.preventDefault).toHaveBeenCalled(); + expect(mockElement2.focus).toHaveBeenCalled(); + }); + + it('should focus previous element on ArrowUp', () => { + const currentSelectedIndex = 1; + spyOn(mockArrowUpEvent, 'preventDefault'); + spyOn(mockElement1, 'focus'); + + component.onArrowUp( + mockArrowUpEvent, + currentSelectedIndex, + mockSiblingElements + ); + + expect(mockArrowUpEvent.preventDefault).toHaveBeenCalled(); + expect(mockElement1.focus).toHaveBeenCalled(); + }); + + it('should expand option on ArrowRight', () => { + Object.defineProperty(component, 'expanded', { + writable: true, + value: false, + }); + spyOn(component, 'toggleItem'); + spyOn(component, 'restoreFocus'); + + component.onArrowRight(mockArrowRightEvent); + + expect(component.toggleItem).toHaveBeenCalledWith(mockArrowRightEvent); + expect(component.restoreFocus).toHaveBeenCalled(); + }); + + it('should collapse option on ArrowLeft', () => { + Object.defineProperty(component, 'expanded', { + writable: true, + value: true, + }); + spyOn(component, 'toggleItem'); + spyOn(component, 'restoreFocus'); + + component.onArrowLeft(mockArrowLeftEvent); + + expect(component.toggleItem).toHaveBeenCalledWith(mockArrowLeftEvent); + expect(component.restoreFocus).toHaveBeenCalled(); + }); + + it('should restore focus after tree toggle', fakeAsync(() => { + const mockElement = document.createElement('a'); + mockElement.id = 'mockElement'; + document.body.appendChild(mockElement); + spyOnProperty(document, 'activeElement').and.returnValue(mockElement); + const treeToggle$ = new Subject(); + component['unitTreeService'] = { + treeToggle$: treeToggle$.asObservable(), + } as any; + spyOn(mockElement, 'focus'); + + component.restoreFocus(); + treeToggle$.next(); + tick(); + + expect(mockElement.focus).toHaveBeenCalled(); + })); + }); }); diff --git a/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.ts b/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.ts index e097f305ceb..5d77acbe41d 100644 --- a/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.ts +++ b/feature-libs/organization/administration/components/unit/list/toggle-link/toggle-link-cell.component.ts @@ -4,13 +4,26 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core'; -import { B2BUnit } from '@spartacus/core'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + HostBinding, + Optional, + inject, +} from '@angular/core'; +import { + B2BUnit, + FeatureConfigService, + RoutingService, + useFeatureStyles, +} from '@spartacus/core'; import { B2BUnitTreeNode } from '@spartacus/organization/administration/core'; import { OutletContextData, TableDataOutletContext, } from '@spartacus/storefront'; +import { take } from 'rxjs'; import { CellComponent } from '../../../shared/table/cell.component'; import { UnitTreeService } from '../../services/unit-tree.service'; @@ -25,21 +38,26 @@ export class ToggleLinkCellComponent extends CellComponent { return this.model.depthLevel; } + @Optional() protected elementRef = inject(ElementRef, { optional: true }); + @Optional() protected routingService = inject(RoutingService, { + optional: true, + }); + @Optional() protected featureConfigService = inject(FeatureConfigService, { + optional: true, + }); + constructor( protected outlet: OutletContextData, protected unitTreeService: UnitTreeService ) { super(outlet); + useFeatureStyles('a11yUnitsListKeyboardControls'); } get combinedName() { return this.property ? `${this.property} (${this.count})` : ''; } - get tabIndex() { - return 0; - } - get expanded() { return this.model.expanded; } @@ -79,4 +97,95 @@ export class ToggleLinkCellComponent extends CellComponent { const { _field, _options, _type, _i18nRoot, ...all } = this.outlet.context; return all as B2BUnit; } + + onKeydown(event: KeyboardEvent) { + // TODO: (CXSPA-6804) - Remove feature flag next major release + if ( + !this.featureConfigService?.isEnabled('a11yUnitsListKeyboardControls') + ) { + return; + } + const tableElement = this.elementRef?.nativeElement.closest('table'); + const siblingElements = tableElement.querySelectorAll( + `cx-org-toggle-link-cell a` + ); + const currentSelectedIndex = Array.from(siblingElements).findIndex( + (element) => { + return element === event.target; + } + ); + + switch (event.key) { + case 'ArrowDown': + this.onArrowDown(event, currentSelectedIndex, siblingElements); + break; + case 'ArrowUp': + this.onArrowUp(event, currentSelectedIndex, siblingElements); + break; + case 'ArrowRight': + this.onArrowRight(event); + break; + case 'ArrowLeft': + this.onArrowLeft(event); + break; + case ' ': + case 'Enter': + this.onSpace(event, siblingElements); + break; + } + } + + onSpace(event: KeyboardEvent, siblingElements: HTMLElement[]): void { + event.preventDefault(); + siblingElements.forEach((element) => { + element.tabIndex = -1; + }); + (event.target as HTMLElement).tabIndex = 0; + this.routingService + ?.go({ cxRoute: this.route, params: this.routeModel }) + .then(() => { + this.restoreFocus(); + }); + } + + onArrowDown( + event: KeyboardEvent, + currentSelectedIndex: number, + siblingElements: HTMLElement[] + ): void { + event.preventDefault(); + siblingElements[currentSelectedIndex + 1]?.focus(); + } + + onArrowUp( + event: KeyboardEvent, + currentSelectedIndex: number, + siblingElements: HTMLElement[] + ): void { + event.preventDefault(); + siblingElements[currentSelectedIndex + -1]?.focus(); + } + + onArrowRight(event: KeyboardEvent): void { + if (!this.expanded && this.isSwitchable) { + this.toggleItem(event); + this.restoreFocus(); + } + } + + onArrowLeft(event: KeyboardEvent): void { + if (this.expanded && this.isSwitchable) { + this.toggleItem(event); + this.restoreFocus(); + } + } + + restoreFocus(): void { + const focusedElementId = document.activeElement?.id || ''; + this.unitTreeService.treeToggle$.pipe(take(1)).subscribe(() => { + setTimeout(() => { + document.getElementById(focusedElementId)?.focus(); + }, 0); + }); + } } diff --git a/feature-libs/organization/administration/styles/_list.scss b/feature-libs/organization/administration/styles/_list.scss index 1c9de69e3aa..8146439f721 100644 --- a/feature-libs/organization/administration/styles/_list.scss +++ b/feature-libs/organization/administration/styles/_list.scss @@ -442,7 +442,20 @@ } } } + // TODO: (CXSPA-6804) - Remove feature flag next major release + @include forFeature('a11yUnitsListKeyboardControls') { + cx-org-active-link-cell a, + cx-org-toggle-link-cell a { + color: var(--cx-color-primary); + text-decoration: underline; + } + } a { + // TODO: (CXSPA-6804) - Remove feature flag next major release + @include forFeature('a11yUnitsListKeyboardControls') { + color: inherit; + text-decoration: none; + } display: flex; align-items: center; width: 100%; @@ -458,7 +471,7 @@ padding-inline-start: 0; } } - + // TODO: (CXSPA-6804) - Remove feature flag next major release &[tabindex='0'], &[tabindex='0']:hover { text-decoration: underline; @@ -470,6 +483,19 @@ color: inherit; text-decoration: none; } + @include forFeature('a11yUnitsListKeyboardControls') { + &[tabindex='0'], + &[tabindex='0']:hover { + text-decoration: unset; + color: unset; + } + + &[tabindex='-1'], + &[tabindex='-1']:hover { + color: unset; + text-decoration: unset; + } + } } } diff --git a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts index 4b550f235f8..3081a7b2840 100644 --- a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -153,6 +153,13 @@ export interface FeatureTogglesInterface { */ a11yCartSummaryHeadingOrder?: boolean; + /** + * Allows users to navigate through the list of units using the arrow keys. + * Enables keyboard controls inside 'ToggleLinkCellComponent' and + * adjusts 'ListComponent' styles to accomodate. + */ + a11yUnitsListKeyboardControls?: boolean; + /** * When set to `true`, product titles in `CartItemComponent`, `QuickOrderItemComponent`, `WishListItemComponent` * adopt a more link-like style, appearing blue with an underline. This enhances visual cues for clickable elements, @@ -188,5 +195,6 @@ export const defaultFeatureToggles: Required = { a11yListOversizedFocus: false, a11yStoreFinderOverflow: false, a11yCartSummaryHeadingOrder: false, + a11yUnitsListKeyboardControls: false, a11yCartItemsLinksStyles: false, }; diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/my-company/units.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/my-company/units.e2e.cy.ts index 849d1351c96..ea69fb1c779 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/my-company/units.e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/my-company/units.e2e.cy.ts @@ -5,6 +5,57 @@ */ import { unitConfig } from '../../../../helpers/b2b/my-company/config/unit'; -import { testMyCompanyFeatureFromConfig } from '../../../../helpers/b2b/my-company/my-company.utils'; +import { + loginAsMyCompanyAdmin, + testMyCompanyFeatureFromConfig, +} from '../../../../helpers/b2b/my-company/my-company.utils'; testMyCompanyFeatureFromConfig(unitConfig, true); + +describe('A11y - Units List Keyboard Controls', () => { + beforeEach(() => { + loginAsMyCompanyAdmin(); + cy.visit(`/organization/units`); + }); + + it('navigate to next link on arrow down', () => { + cy.get('#Rustic').focus(); + cy.focused().type('{downArrow}'); + cy.focused().should('have.id', 'Rustic Retail'); + cy.focused().type('{downArrow}'); + cy.focused().should('have.id', 'Rustic Services'); + cy.focused().type('{downArrow}'); + cy.focused().should('have.id', 'Rustic Services'); + }); + + it('navigate to previous link on arrow up', () => { + cy.get('[id="Rustic Services"]').focus(); + cy.focused().type('{upArrow}'); + cy.focused().should('have.id', 'Rustic Retail'); + cy.focused().type('{upArrow}'); + cy.focused().should('have.id', 'Rustic'); + cy.focused().type('{upArrow}'); + cy.focused().should('have.id', 'Rustic'); + }); + + it('collapses option on arrow left', () => { + cy.get('#Rustic').focus(); + cy.focused().type('{leftArrow}'); + cy.get('[id="Rustic Retail"]').should('not.exist'); + }); + + it('expands option on arrow right', () => { + cy.get('[id="Rustic Services"]').focus(); + cy.focused().type('{rightArrow}'); + cy.focused().type('{downArrow}'); + cy.focused().should('have.id', 'Services East'); + }); + + it('focuses on active option while navigating back to list', () => { + cy.get('[id="Rustic Services"]').focus(); + cy.focused().type(' '); + cy.focused().parents('cx-org-card').should('exist'); + cy.focused().pressTab(true); + cy.focused().should('have.id', 'Rustic Services'); + }); +}); diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 2996a0ee506..5eaa5a24e4c 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -287,6 +287,7 @@ if (environment.requestedDeliveryDate) { a11yListOversizedFocus: true, a11yStoreFinderOverflow: true, a11yCartSummaryHeadingOrder: true, + a11yUnitsListKeyboardControls: true, a11yCartItemsLinksStyles: true, }; return appFeatureToggles; From 064d23776413afc628f8e34c4dbee9a42224e752 Mon Sep 17 00:00:00 2001 From: Roman <129765378+rmch91@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:03:10 +0200 Subject: [PATCH 16/18] fix: Fix image lazy loading for Chrome in SSR mode (#18761) This PR provides the work around for not working image lazy loading in SSR in Chrome and Safari. Closes: https://jira.tools.sap/browse/CXSPA-6719 --- .../components/media/media.component.html | 58 +++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/projects/storefrontlib/shared/components/media/media.component.html b/projects/storefrontlib/shared/components/media/media.component.html index b54aea492d3..50d303c2214 100644 --- a/projects/storefrontlib/shared/components/media/media.component.html +++ b/projects/storefrontlib/shared/components/media/media.component.html @@ -1,26 +1,36 @@ - - + + + - - + + - - - + + + + From 65c0203a94fed94dc63790ae35e0d0edd65956be Mon Sep 17 00:00:00 2001 From: sdrozdsap <163305268+sdrozdsap@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:32:56 +0100 Subject: [PATCH 17/18] test: Failing scroll-position-restoration spec (#18772) --- .../scroll-position-restoration.e2e.cy.ts | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/scrolling/scroll-position-restoration.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/scrolling/scroll-position-restoration.e2e.cy.ts index 5ba47e29db9..7572fa1d85b 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/scrolling/scroll-position-restoration.e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/scrolling/scroll-position-restoration.e2e.cy.ts @@ -6,28 +6,47 @@ context('scroll Position Restoration', () => { it('should restore scroll position', () => { + cy.intercept({ + method: 'GET', + pathname: `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/cms/pages`, + query: { + pageType: 'ProductPage', + }, + }).as('getPage'); + cy.visit('/'); cy.log('Go to category page'); cy.get('cx-category-navigation a').eq(0).click(); cy.get('cx-product-list-item').should('exist'); - cy.get('cx-product-list-item').eq(3).scrollIntoView(); - cy.get('cx-product-list-item .cx-product-name').eq(3).click(); - - cy.log('Go to product details page'); - cy.get('.ProductDetailsPageTemplate').should('exist'); - cy.window().scrollTo('bottom'); - - cy.log('Go back to product list'); - cy.go(-1); - cy.window().its('scrollY').should('be.greaterThan', 0); - - cy.log('Go forward to product details'); - cy.go(1); - cy.get('.ProductDetailsPageTemplate').should('exist'); - cy.window().then(($window) => { - expect($window.scrollY).to.be.greaterThan(0); - }); + cy.get('cx-product-list-item .cx-product-name') + .eq(3) + .then(($productItem) => { + const productName = $productItem.text(); + cy.wrap($productItem).scrollIntoView().click(); + + cy.log('Go to product details page'); + verifyProductPageLoaded(productName); + cy.window().scrollTo('bottom'); + + cy.log('Go back to product list'); + cy.go(-1); + cy.window().its('scrollY').should('be.greaterThan', 0); + + cy.log('Go forward to product details'); + cy.go(1); + verifyProductPageLoaded(productName); + cy.window().then(($window) => { + expect($window.scrollY).to.be.greaterThan(0); + }); + }); }); }); + +const verifyProductPageLoaded = (productName: string) => { + cy.wait('@getPage').its('response.statusCode').should('eq', 200); + cy.get(`cx-breadcrumb h1`).should('contain', productName); +}; From dc3b5d868abbf04ab88f04a17c6045c690c2de01 Mon Sep 17 00:00:00 2001 From: Hak Woo Kim Date: Fri, 26 Apr 2024 11:32:29 -0400 Subject: [PATCH 18/18] fix: tag event shouldn't trigger if consent is unavailable (CXSPA-6932) (#18771) Co-authored-by: Hakwoo Kim Co-authored-by: Radhep Sabapathipillai <34665674+RadhepS@users.noreply.github.com> --- .../profile-tag-lifecycle.service.spec.ts | 166 +++++++----------- .../services/profile-tag-lifecycle.service.ts | 9 +- .../effects/anonymous-consents.effect.ts | 2 + 3 files changed, 74 insertions(+), 103 deletions(-) diff --git a/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.spec.ts b/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.spec.ts index f97f291b3ab..277621edaa8 100644 --- a/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.spec.ts +++ b/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.spec.ts @@ -1,122 +1,88 @@ import { TestBed } from '@angular/core/testing'; -import { - Event as NgRouterEvent, - NavigationStart, - Router, -} from '@angular/router'; -import { Action, ActionsSubject } from '@ngrx/store'; -import { ActiveCartFacade, Cart } from '@spartacus/cart/base/root'; -import { AuthActions, ConsentService } from '@spartacus/core'; -import { BehaviorSubject, ReplaySubject } from 'rxjs'; -import { filter, tap } from 'rxjs/operators'; -import { CdsConfig } from '../../config'; +import { ActionsSubject, StoreModule } from '@ngrx/store'; +import { ConsentService } from '@spartacus/core'; +import { of } from 'rxjs'; +import { CdsConfig } from '../../config/cds-config'; +import { ConsentChangedPushEvent } from '../model/profile-tag.model'; import { ProfileTagLifecycleService } from './profile-tag-lifecycle.service'; +import { fakeAsync, tick, flush } from '@angular/core/testing'; -describe('profileTagLifecycleService', () => { - let profileTagLifecycleService: ProfileTagLifecycleService; - let getConsentBehavior; - let isConsentGivenValue; - let routerEventsBehavior; - let router; - let consentsService; - let activeCartService; - let cartBehavior; - let mockActionsSubject: ReplaySubject; +describe('ProfileTagLifecycleService', () => { + let service: ProfileTagLifecycleService; + let consentService: jasmine.SpyObj; + let actionsSubject: ActionsSubject; - const mockCDSConfig: CdsConfig = { - cds: { - consentTemplateId: 'PROFILE', - }, - }; - function setVariables() { - getConsentBehavior = new BehaviorSubject(undefined); - isConsentGivenValue = false; - routerEventsBehavior = new BehaviorSubject( - new NavigationStart(0, 'test.com', 'popstate') - ); - cartBehavior = new ReplaySubject(); - mockActionsSubject = new ReplaySubject(); - consentsService = { - getConsent: () => getConsentBehavior, - isConsentGiven: () => isConsentGivenValue, - }; - router = { - events: routerEventsBehavior, - }; - activeCartService = { - getActive: () => cartBehavior, - }; - } beforeEach(() => { - setVariables(); + const consentServiceSpy = jasmine.createSpyObj('ConsentService', [ + 'getConsent', + 'isConsentGiven', + ]); TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], providers: [ - { provide: Router, useValue: router }, - { - provide: ConsentService, - useValue: consentsService, - }, - { - provide: ActiveCartFacade, - useValue: activeCartService, - }, + { provide: ConsentService, useValue: consentServiceSpy }, { provide: CdsConfig, - useValue: mockCDSConfig, - }, - { - provide: ActionsSubject, - useValue: mockActionsSubject, + useValue: { cds: { consentTemplateId: 'templateId' } }, }, + ActionsSubject, + ProfileTagLifecycleService, ], }); - profileTagLifecycleService = TestBed.inject(ProfileTagLifecycleService); + service = TestBed.inject(ProfileTagLifecycleService); + consentService = TestBed.inject( + ConsentService + ) as jasmine.SpyObj; + + actionsSubject = TestBed.inject(ActionsSubject); }); it('should be created', () => { - expect(profileTagLifecycleService).toBeTruthy(); + expect(service).toBeTruthy(); }); - describe('Consent', () => { - it(`Should emit an event if the profile consent changes to true,`, () => { - let timesCalled = 0; - const subscription = profileTagLifecycleService - .consentChanged() - .pipe(tap(() => timesCalled++)) - .subscribe(); - isConsentGivenValue = true; - getConsentBehavior.next({ consent: 'test' }); - subscription.unsubscribe(); - expect(timesCalled).toEqual(1); + + it('Should emit an event if the profile consent changes to true,', (done: DoneFn) => { + const mockConsent = { code: 'TestCode' }; + consentService.getConsent.and.returnValue(of(mockConsent)); + consentService.isConsentGiven.and.returnValue(true); + + service.consentChanged().subscribe((event: ConsentChangedPushEvent) => { + expect(event.data.granted).toBe(true); + done(); }); - it(`Should emit an event if the profile consent changes to false,`, () => { - let timesCalled = 0; - const subscription = profileTagLifecycleService - .consentChanged() - .pipe( - filter((event) => Boolean(!event.data.granted)), - tap(() => timesCalled++) - ) - .subscribe(); - isConsentGivenValue = false; - getConsentBehavior.next({ consent: 'test' }); - subscription.unsubscribe(); - expect(timesCalled).toEqual(1); + }); + + it('Should emit an event if the profile consent changes to false,', (done: DoneFn) => { + const mockConsent = { code: 'TestCode' }; + consentService.getConsent.and.returnValue(of(mockConsent)); + consentService.isConsentGiven.and.returnValue(false); + + service.consentChanged().subscribe((event: ConsentChangedPushEvent) => { + expect(event.data.granted).toBe(false); + done(); }); }); - it(`Should call the push method first time a login is successful`, () => { - let timesCalled = 0; - const subscription = profileTagLifecycleService - .loginSuccessful() - .pipe(tap((_) => timesCalled++)) - .subscribe(); - mockActionsSubject.next({ type: AuthActions.LOGOUT }); - mockActionsSubject.next({ type: AuthActions.LOGIN }); - mockActionsSubject.next({ type: AuthActions.LOGOUT }); - mockActionsSubject.next({ type: AuthActions.LOGOUT }); - mockActionsSubject.next({ type: AuthActions.LOGIN }); - mockActionsSubject.next({ type: AuthActions.LOGOUT }); - subscription.unsubscribe(); - expect(timesCalled).toEqual(2); + it('Should emit an event if the profile consent changes to false if consent is undefined,', (done: DoneFn) => { + const mockConsent = undefined; + consentService.getConsent.and.returnValue(of(mockConsent)); + consentService.isConsentGiven.and.returnValue(true); + + service.consentChanged().subscribe((event: ConsentChangedPushEvent) => { + expect(event.data.granted).toBe(false); + done(); + }); }); + + it('should return login successful event', fakeAsync(() => { + const mockAction = { type: 'LOGIN' }; + actionsSubject.next(mockAction); + tick(); + + service.loginSuccessful().subscribe((result: boolean) => { + expect(result).toBe(true); + }); + + flush(); + })); }); diff --git a/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.ts b/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.ts index 9ab53b2a343..558cb778630 100644 --- a/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.ts +++ b/integration-libs/cds/src/profiletag/services/profile-tag-lifecycle.service.ts @@ -6,7 +6,7 @@ import { Injectable } from '@angular/core'; import { ActionsSubject } from '@ngrx/store'; -import { AuthActions, ConsentService, isNotUndefined } from '@spartacus/core'; +import { AuthActions, ConsentService } from '@spartacus/core'; import { Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { CdsConfig } from '../../config/cds-config'; @@ -26,9 +26,12 @@ export class ProfileTagLifecycleService { return this.consentService .getConsent(this.config.cds?.consentTemplateId ?? '') .pipe( - filter(isNotUndefined), map((profileConsent) => { - return this.consentService.isConsentGiven(profileConsent); + if (profileConsent) { + return this.consentService.isConsentGiven(profileConsent); + } else { + return false; + } }), map((granted) => { return new ConsentChangedPushEvent(granted); diff --git a/projects/core/src/anonymous-consents/store/effects/anonymous-consents.effect.ts b/projects/core/src/anonymous-consents/store/effects/anonymous-consents.effect.ts index a7f5b22c1ca..59df1c71f78 100644 --- a/projects/core/src/anonymous-consents/store/effects/anonymous-consents.effect.ts +++ b/projects/core/src/anonymous-consents/store/effects/anonymous-consents.effect.ts @@ -15,6 +15,7 @@ import { map, mergeMap, switchMap, + take, tap, withLatestFrom, } from 'rxjs/operators'; @@ -205,6 +206,7 @@ export class AnonymousConsentsEffects { ), concatMap(() => this.userConsentService.getConsentsResultSuccess().pipe( + take(1), withLatestFrom( this.userIdService.getUserId(), this.userConsentService.getConsents(),