From 35ee2bc0984925050cc2d03691b22a6a3f36742f Mon Sep 17 00:00:00 2001 From: Sergiej Drozd Date: Mon, 21 Oct 2024 14:32:07 +0200 Subject: [PATCH] feat: Use tabs instead of radio group for pickup options --- .../add-to-cart/add-to-cart.component.html | 3 +- .../add-to-cart/add-to-cart.component.ts | 45 +- .../assets/translations/en/pickupInStore.json | 5 +- ...cart-pickup-options-container.component.ts | 10 +- ...pickup-options-container.component.spec.ts | 14 +- .../pdp-pickup-options-container.component.ts | 36 +- .../pickup-option-dialog.component.ts | 36 +- .../pickup-options.component.html | 371 +++++++++------- .../pickup-options.component.spec.ts | 405 +++++++++++++----- .../pickup-options.component.ts | 106 ++++- .../pickup-options/pickup-options.module.ts | 2 + .../styles/_pickup-options.scss | 90 +++- .../feature-toggles/config/feature-toggles.ts | 11 + .../spartacus/spartacus-features.module.ts | 1 + .../cms-components/content/index.ts | 4 + .../tab/panel/tab-panel.component.html | 9 +- .../content/tab/tab.component.html | 2 + .../content/tab/tab.component.ts | 67 ++- .../cms-components/content/tab/tab.model.ts | 5 + .../scss/components/content/tab/_tab.scss | 49 ++- 20 files changed, 928 insertions(+), 343 deletions(-) diff --git a/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.html b/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.html index 489b2f32960..3f56fe366a1 100644 --- a/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.html +++ b/feature-libs/cart/base/components/add-to-cart/add-to-cart.component.html @@ -24,6 +24,7 @@ @@ -36,7 +37,7 @@ : 'btn btn-primary btn-block' " type="submit" - [disabled]="quantity <= 0 || quantity > maxQuantity" + [disabled]="disabled || quantity <= 0 || quantity > maxQuantity" > { let intendedPickupLocationService: IntendedPickupLocationFacade; let currentProductService: CurrentProductService; let preferredStoreFacade: PreferredStoreFacade; + let featureConfigService: FeatureConfigService; const configureTestingModule = () => TestBed.configureTestingModule({ @@ -145,6 +146,7 @@ describe('PdpPickupOptionsComponent', () => { preferredStoreFacade = TestBed.inject(PreferredStoreFacade); currentProductService = TestBed.inject(CurrentProductService); + featureConfigService = TestBed.inject(FeatureConfigService); spyOn(currentProductService, 'getProduct').and.callThrough(); spyOn(launchDialogService, 'openDialog').and.callThrough(); @@ -218,13 +220,23 @@ describe('PdpPickupOptionsComponent', () => { expect(component.openDialog).not.toHaveBeenCalled(); }); - it('should open dialog if displayName is not set on pickup option change', () => { + it('should open dialog if displayName is not set and a11yPickupOptionsTabs disabled', () => { + spyOn(featureConfigService, 'isEnabled').and.returnValue(false); spyOn(component, 'openDialog'); component['displayNameIsSet'] = false; const option = 'pickup'; component.onPickupOptionChange(option); expect(component.openDialog).toHaveBeenCalled(); }); + + it('should NOT open dialog if displayName is not set and a11yPickupOptionsTabs enabled', () => { + spyOn(featureConfigService, 'isEnabled').and.returnValue(true); + spyOn(component, 'openDialog'); + component['displayNameIsSet'] = false; + const option = 'pickup'; + component.onPickupOptionChange(option); + expect(component.openDialog).not.toHaveBeenCalled(); + }); }); describe('without current product', () => { beforeEach(() => { diff --git a/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts b/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts index 9521bb3af46..21d0f5ae364 100644 --- a/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts +++ b/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts @@ -8,8 +8,10 @@ import { Component, ElementRef, inject, + EventEmitter, OnDestroy, OnInit, + Output, ViewChild, ViewContainerRef, } from '@angular/core'; @@ -63,6 +65,9 @@ export class PdpPickupOptionsContainerComponent implements OnInit, OnDestroy { * The 'triggerElement' is passed through 'PickupOptionChange' event instead. */ @ViewChild('open') element: ElementRef; + @Output() intendedPickupChange = new EventEmitter< + AugmentedPointOfService | undefined + >(); subscription = new Subscription(); availableForPickup = false; @@ -147,6 +152,10 @@ export class PdpPickupOptionsContainerComponent implements OnInit, OnDestroy { ) ); + this.subscription.add( + this.intendedPickupLocation$.subscribe(this.intendedPickupChange) + ); + this.subscription.add( combineLatest([ productCode$, @@ -202,6 +211,19 @@ export class PdpPickupOptionsContainerComponent implements OnInit, OnDestroy { onPickupOptionChange( event: { option: PickupOption; triggerElement: ElementRef } | PickupOption ): void { + const handleChange = ( + option: PickupOption, + triggerElement?: ElementRef + ) => { + if (!this.featureConfigService.isEnabled('a11yPickupOptionsTabs')) { + if (option === 'delivery') { + return; + } + if (!this.displayNameIsSet) { + this.openDialog(triggerElement); + } + } + }; if ( this.featureConfigService.isEnabled('a11yDialogTriggerRefocus') && typeof event === 'object' @@ -211,23 +233,13 @@ export class PdpPickupOptionsContainerComponent implements OnInit, OnDestroy { this.productCode, option ); - if (option === 'delivery') { - return; - } - if (!this.displayNameIsSet) { - this.openDialog(triggerElement); - } + handleChange(option, triggerElement); } else if (typeof event === 'string') { this.intendedPickupLocationService.setPickupOption( this.productCode, event ); - if (event === 'delivery') { - return; - } - if (!this.displayNameIsSet) { - this.openDialog(); - } + handleChange(event); } } } diff --git a/feature-libs/pickup-in-store/components/container/pickup-option-dialog/pickup-option-dialog.component.ts b/feature-libs/pickup-in-store/components/container/pickup-option-dialog/pickup-option-dialog.component.ts index cf559ddb791..cefb5951ba7 100644 --- a/feature-libs/pickup-in-store/components/container/pickup-option-dialog/pickup-option-dialog.component.ts +++ b/feature-libs/pickup-in-store/components/container/pickup-option-dialog/pickup-option-dialog.component.ts @@ -164,25 +164,27 @@ export class PickupOptionDialogComponent implements OnInit, OnDestroy { close(reason: string): void { this.launchDialogService.closeDialog(reason); if (reason === this.CLOSE_WITHOUT_SELECTION) { - this.intendedPickupLocationService - .getIntendedLocation(this.productCode) - .pipe( - filter( - (store: AugmentedPointOfService | undefined) => - typeof store !== 'undefined' - ), - map((store) => store as AugmentedPointOfService), - filter((store) => !store.name), - take(1), - tap(() => - this.intendedPickupLocationService.setPickupOption( - this.productCode, - 'delivery' + if (!this.featureConfigService.isEnabled('a11yPickupOptionsTabs')) { + this.intendedPickupLocationService + .getIntendedLocation(this.productCode) + .pipe( + filter( + (store: AugmentedPointOfService | undefined) => + typeof store !== 'undefined' + ), + map((store) => store as AugmentedPointOfService), + filter((store) => !store.name), + take(1), + tap(() => + this.intendedPickupLocationService.setPickupOption( + this.productCode, + 'delivery' + ) ) ) - ) - .subscribe(); - this.pickupOptionFacade.setPickupOption(this.entryNumber, 'delivery'); + .subscribe(); + this.pickupOptionFacade.setPickupOption(this.entryNumber, 'delivery'); + } return; } this.subscription.add( diff --git a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.html b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.html index 2c2b3da2cfa..2df9db5fffd 100644 --- a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.html +++ b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.html @@ -1,159 +1,216 @@ -
-
- {{ 'pickupOptions.legend' | cxTranslate }} -
- - + + +
+ {{ 'pickupOptions.legend' | cxTranslate }} +
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ +
+ +
+
+
+ {{ 'pickupOptions.legend' | cxTranslate }} +
+ +
-
- - +
+ + {{ validationError | cxTranslate }} +
-
- -
- - -
-
- - -
-
- + + + {{ + 'pickupOptions.freeReturn' | cxTranslate + }} + + + + {{ displayPickupLocation }} + + + + + + diff --git a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.spec.ts b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.spec.ts index f0c52beea08..4971b2a639a 100644 --- a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.spec.ts +++ b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.spec.ts @@ -1,12 +1,58 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { + Component, + Directive, + Input, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { FeatureConfigService, I18nTestingModule } from '@spartacus/core'; import { PickupOption } from '@spartacus/pickup-in-store/root'; -import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; import { Observable } from 'rxjs'; -import { PickupOptionsComponent } from './pickup-options.component'; +import { + PickupOptionsComponent, + PickupOptionsTabs, +} from './pickup-options.component'; +import { TAB_MODE, Tab, TabConfig, TabModule } from '@spartacus/storefront'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; +import { By } from '@angular/platform-browser'; + +@Component({ + selector: 'cx-tab', + template: `
`, +}) +class MockTabComponent { + @Input() disabled: boolean; + @Input() tabs: Tab[]; + @Input() config: TabConfig; +} + +// Reverted mock directive used to check whether all parts of the component works properly +// if the feature flag is disabled. +@Directive({ + selector: '[cxFeature]', +}) +export class MockRevertedFeatureDirective { + constructor( + protected templateRef: TemplateRef, + protected viewContainer: ViewContainerRef + ) {} + + @Input() set cxFeature(_feature: string) { + // ensure the deprecated DOM changes are not rendered during tests + + if (_feature.toString().includes('!')) { + this.viewContainer.createEmbeddedView(this.templateRef); + } + } +} +class MockRevertedFeatureConfigService { + isEnabled() { + return false; + } +} class MockFeatureConfigService { isEnabled() { @@ -18,136 +64,295 @@ describe('PickupOptionsComponent', () => { let component: PickupOptionsComponent; let fixture: ComponentFixture; - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [PickupOptionsComponent, MockFeatureDirective], - imports: [CommonModule, I18nTestingModule, ReactiveFormsModule], - providers: [ - { provide: FeatureConfigService, useClass: MockFeatureConfigService }, - ], + describe('with feature flags disabled', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + PickupOptionsComponent, + MockRevertedFeatureDirective, + MockTabComponent, + ], + imports: [CommonModule, I18nTestingModule, ReactiveFormsModule], + providers: [ + { + provide: FeatureConfigService, + useClass: MockRevertedFeatureConfigService, + }, + ], + }); + fixture = TestBed.createComponent(PickupOptionsComponent); + component = fixture.componentInstance; + }); + it('should create', () => { + expect(component).toBeDefined(); }); - fixture = TestBed.createComponent(PickupOptionsComponent); - component = fixture.componentInstance; - }); - it('should create', () => { - expect(component).toBeDefined(); - }); + it('should set value of the form to the selected option whenever it changes', () => { + component.selectedOption = 'delivery'; + component.ngOnChanges(); + expect(component.pickupOptionsForm.get('pickupOption')?.value).toBe( + 'delivery' + ); - it('should set value of the form to the selected option whenever it changes', () => { - component.selectedOption = 'delivery'; - component.ngOnChanges(); - expect(component.pickupOptionsForm.get('pickupOption')?.value).toBe( - 'delivery' - ); - - component.selectedOption = 'pickup'; - component.ngOnChanges(); - expect(component.pickupOptionsForm.get('pickupOption')?.value).toBe( - 'pickup' - ); - }); + component.selectedOption = 'pickup'; + component.ngOnChanges(); + expect(component.pickupOptionsForm.get('pickupOption')?.value).toBe( + 'pickup' + ); + }); - it('should emit the new pickup option on onPickupOptionChange', () => { - spyOn(component.pickupOptionChange, 'emit'); - component.onPickupOptionChange('delivery'); + it('should emit the new pickup option on onPickupOptionChange', () => { + spyOn(component.pickupOptionChange, 'emit'); + component.onPickupOptionChange('delivery'); - expect(component.pickupOptionChange.emit).toHaveBeenCalledWith({ - option: 'delivery', - triggerElement: component.triggerElement, + expect(component.pickupOptionChange.emit).toHaveBeenCalledWith( + 'delivery' + ); }); - }); - - it('should emit on onPickupLocationChange', () => { - spyOn(component.pickupLocationChange, 'emit'); - component.onPickupLocationChange(); - expect(component.pickupLocationChange.emit).toHaveBeenCalled(); - }); + it('should emit on onPickupLocationChange', () => { + spyOn(component.pickupLocationChange, 'emit'); + component.onPickupLocationChange(); - it('should call disable on pickupOption', () => { - component.disableControls = true; - fixture.detectChanges(); - component.ngOnChanges(); - expect(component.pickupOptionsForm.get('pickupOption')?.disabled).toBe( - true - ); - }); + expect(component.pickupLocationChange.emit).toHaveBeenCalled(); + }); - describe('template', () => { - it('should show delivery option', () => { + it('should call disable on pickupOption', () => { + component.disableControls = true; fixture.detectChanges(); - - const label = fixture.debugElement.nativeElement.querySelector( - '[data-pickup="delivery"] + label' + component.ngOnChanges(); + expect(component.pickupOptionsForm.get('pickupOption')?.disabled).toBe( + true ); - expect(label.textContent).toContain('pickupOptions.delivery'); }); - it('should show pickup option and select store when no display location is set', () => { - fixture.detectChanges(); + describe('template', () => { + it('should show delivery option', () => { + fixture.detectChanges(); - const label = fixture.debugElement.nativeElement.querySelector( - '[data-pickup="pickup"] + label' - ); - expect(label.textContent).toContain('pickupOptions.pickup'); - expect(label.textContent).toContain('pickupOptions.selectStore'); + const label = fixture.debugElement.nativeElement.querySelector( + '[data-pickup="delivery"] + label' + ); + expect(label.textContent).toContain('pickupOptions.delivery'); + }); + + it('should show pickup option and select store when no display location is set', () => { + fixture.detectChanges(); + + const label = fixture.debugElement.nativeElement.querySelector( + '[data-pickup="pickup"] + label' + ); + expect(label.textContent).toContain('pickupOptions.pickup'); + expect(label.textContent).toContain('pickupOptions.selectStore'); + }); + + it('should show pickup option and change store when no display location is set', () => { + component.displayPickupLocation = 'Test location'; + fixture.detectChanges(); + + const label = fixture.debugElement.nativeElement.querySelector( + '[data-pickup="pickup"] + label' + ); + expect(label.textContent).toContain('pickupOptions.pickup'); + expect(label.textContent).toContain('pickupOptions.changeStore'); + expect(label.textContent).toContain('Test location'); + }); + + it('should call onPickupOptionChange when the radio buttons are clicked', () => { + spyOn(component, 'onPickupOptionChange'); + fixture.detectChanges(); + + // for delivery + let radioButton = fixture.debugElement.nativeElement.querySelector( + '[data-pickup="delivery"]' + ); + radioButton.click(); + + expect(component.onPickupOptionChange).toHaveBeenCalledWith('delivery'); + + // for pickup + radioButton = fixture.debugElement.nativeElement.querySelector( + '[data-pickup="pickup"]' + ); + radioButton.click(); + + expect(component.onPickupOptionChange).toHaveBeenCalledWith('pickup'); + }); + + it('should call onPickupLocationChange when the select store button is clicked', () => { + spyOn(component, 'onPickupLocationChange'); + fixture.detectChanges(); + + const selectStoreButton = + fixture.debugElement.nativeElement.querySelector('a[role="button"]'); + selectStoreButton.click(); + + expect(component.onPickupLocationChange).toHaveBeenCalled(); + }); + + it('should call onPickupLocationChange when the change store button is clicked', () => { + spyOn(component, 'onPickupLocationChange'); + component.displayPickupLocation = 'Test location'; + fixture.detectChanges(); + + const changeStoreButton = + fixture.debugElement.nativeElement.querySelector('a[role="button"]'); + changeStoreButton.click(); + + expect(component.onPickupLocationChange).toHaveBeenCalled(); + }); }); + }); - it('should show pickup option and change store when no display location is set', () => { - component.displayPickupLocation = 'Test location'; - fixture.detectChanges(); + describe('with feature flags enabled', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [PickupOptionsComponent, MockFeatureDirective], + imports: [ + CommonModule, + I18nTestingModule, + ReactiveFormsModule, + TabModule, + ], + providers: [ + { provide: FeatureConfigService, useClass: MockFeatureConfigService }, + ], + }); + fixture = TestBed.createComponent(PickupOptionsComponent); + component = fixture.componentInstance; + }); - const label = fixture.debugElement.nativeElement.querySelector( - '[data-pickup="pickup"] + label' - ); - expect(label.textContent).toContain('pickupOptions.pickup'); - expect(label.textContent).toContain('pickupOptions.changeStore'); - expect(label.textContent).toContain('Test location'); + it('should create', () => { + expect(component).toBeDefined(); }); - it('should call onPickupOptionChange when the radio buttons are clicked', () => { - spyOn(component, 'onPickupOptionChange'); + it('should select tab to the selected option whenever it changes', () => { + component.selectedOption = 'delivery'; + component.ngOnChanges(); fixture.detectChanges(); + let activeTab = fixture.debugElement.queryAll( + By.css('cx-tab button[role="tab"]') + )[PickupOptionsTabs.DELIVERY].nativeElement; + expect(activeTab.classList.contains('active')).toBeTruthy(); - // for delivery - let radioButton = fixture.debugElement.nativeElement.querySelector( - '[data-pickup="delivery"]' + spyOn(component.tabComponent, 'select').and.callThrough(); + component.selectedOption = 'pickup'; + component.ngOnChanges(); + fixture.detectChanges(); + expect(component.tabComponent?.select).toHaveBeenCalledWith( + PickupOptionsTabs.PICKUP, + TAB_MODE.TAB ); - radioButton.click(); + }); - expect(component.onPickupOptionChange).toHaveBeenCalledWith('delivery'); + it('should emit the new pickup option on onPickupOptionChange', () => { + spyOn(component.pickupOptionChange, 'emit'); + component.onPickupOptionChange('delivery'); - // for pickup - radioButton = fixture.debugElement.nativeElement.querySelector( - '[data-pickup="pickup"]' - ); - radioButton.click(); + expect(component.pickupOptionChange.emit).toHaveBeenCalledWith({ + option: 'delivery', + triggerElement: component.triggerElement, + }); + }); - expect(component.onPickupOptionChange).toHaveBeenCalledWith('pickup'); + it('should emit on onPickupLocationChange', () => { + spyOn(component.pickupLocationChange, 'emit'); + component.onPickupLocationChange(); + + expect(component.pickupLocationChange.emit).toHaveBeenCalled(); }); - it('should call onPickupLocationChange when the select store button is clicked', () => { - spyOn(component, 'onPickupLocationChange'); + it('should disable tabs if disabledControls is true', () => { + component.disableControls = true; fixture.detectChanges(); + component.ngOnChanges(); + const tabs = fixture.debugElement.queryAll( + By.css('cx-tab button[role="tab"]') + ); + tabs.forEach((tab) => expect(tab.nativeElement.disabled).toBeTruthy()); + }); - const selectStoreButton = - fixture.debugElement.nativeElement.querySelector('button'); - selectStoreButton.click(); + describe('template', () => { + it('should show delivery option', () => { + component.selectedOption = 'delivery'; + fixture.detectChanges(); - expect(component.onPickupLocationChange).toHaveBeenCalled(); - }); + const panel = fixture.debugElement.query( + By.css('cx-tab-panel') + ).nativeElement; + expect(panel.textContent).toContain('pickupOptions.freeReturn'); + }); - it('should call onPickupLocationChange when the change store button is clicked', () => { - spyOn(component, 'onPickupLocationChange'); - component.displayPickupLocation = 'Test location'; - fixture.detectChanges(); + it('should show pickup option and select store when no display location is set', () => { + component.selectedOption = 'pickup'; + fixture.detectChanges(); + + const panel = fixture.debugElement.query( + By.css('cx-tab-panel') + ).nativeElement; + expect(panel.textContent).toContain('pickupOptions.selectStore'); + }); + + it('should show pickup option and change store when display location is set', () => { + component.selectedOption = 'pickup'; + component.displayPickupLocation = 'Test location'; + fixture.detectChanges(); + + const panel = fixture.debugElement.query( + By.css('cx-tab-panel') + ).nativeElement; + expect(panel.textContent).toContain('pickupOptions.changeStore'); + expect(panel.textContent).toContain('Test location'); + }); + + it('should call onPickupOptionChange when the tab is changed', () => { + spyOn(component, 'onPickupOptionChange'); + fixture.detectChanges(); + + // for delivery + let tabButton = fixture.debugElement.queryAll(By.css('button'))[ + PickupOptionsTabs.DELIVERY + ].nativeElement; + tabButton.click(); + + expect(component.onPickupOptionChange).toHaveBeenCalledWith('delivery'); + + // for pickup + tabButton = fixture.debugElement.queryAll(By.css('button'))[ + PickupOptionsTabs.PICKUP + ].nativeElement; + tabButton.click(); + + expect(component.onPickupOptionChange).toHaveBeenCalledWith('pickup'); + }); + + it('should call onPickupLocationChange when the select store button is clicked', () => { + spyOn(component, 'onPickupLocationChange'); + fixture.detectChanges(); + + const selectStoreButton = fixture.debugElement.query( + By.css('button[data-store-location-link]') + ).nativeElement; + selectStoreButton.click(); + + expect(component.onPickupLocationChange).toHaveBeenCalled(); + }); + + it('should call onPickupLocationChange when the change store button is clicked', () => { + fixture.detectChanges(); + spyOn(component, 'onPickupLocationChange'); + component.selectedOption = 'pickup'; + component.displayPickupLocation = 'Test location'; + component.ngOnChanges(); + fixture.detectChanges(); - const changeStoreButton = - fixture.debugElement.nativeElement.querySelector('button'); - changeStoreButton.click(); + const changeStoreButton = fixture.debugElement.query( + By.css('button[data-store-location-link]') + ).nativeElement; + changeStoreButton.click(); - expect(component.onPickupLocationChange).toHaveBeenCalled(); + expect(component.onPickupLocationChange).toHaveBeenCalled(); + }); }); }); }); diff --git a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts index a3a310639c3..80516cb3304 100644 --- a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts +++ b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts @@ -5,19 +5,29 @@ */ import { + AfterViewInit, + ChangeDetectorRef, Component, ElementRef, EventEmitter, inject, Input, OnChanges, + OnDestroy, Output, ViewChild, + TemplateRef, } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { FeatureConfigService, useFeatureStyles } from '@spartacus/core'; import { PickupOption } from '@spartacus/pickup-in-store/root'; +import { TAB_MODE, Tab, TabComponent, TabConfig } from '@spartacus/storefront'; +import { Subscription, take } from 'rxjs'; +export enum PickupOptionsTabs { + 'DELIVERY', + 'PICKUP', +} /** * The presentational component of a pair of radio buttons for pickup options for a product. */ @@ -25,7 +35,10 @@ import { PickupOption } from '@spartacus/pickup-in-store/root'; selector: 'cx-pickup-options', templateUrl: './pickup-options.component.html', }) -export class PickupOptionsComponent implements OnChanges { +export class PickupOptionsComponent + implements OnChanges, AfterViewInit, OnDestroy +{ + protected subscription = new Subscription(); /** The selected option, either `'pickup'` or `'delivery'`. */ @Input() selectedOption: PickupOption; /** The location to display in the pickup option. */ @@ -46,25 +59,60 @@ export class PickupOptionsComponent implements OnChanges { @ViewChild('dialogTriggerEl') triggerElement: ElementRef; - private featureConfigService = inject(FeatureConfigService); - pickupId = `pickup-id:${Math.random().toString(16)}`; deliveryId = `delivery-id:${Math.random().toString(16)}`; pickupOptionsForm = new FormGroup({ pickupOption: new FormControl(null), }); + tabs: Tab[]; + tabConfig: TabConfig; + + protected cdr = inject(ChangeDetectorRef); + private featureConfigService = inject(FeatureConfigService); + + @ViewChild('deliveryTabPanel') deliveryTabPanel: TemplateRef; + @ViewChild('pickupTabPanel') pickupTabPanel: TemplateRef; + @ViewChild(TabComponent) tabComponent: TabComponent | undefined; + + get validationError() { + if (this.selectedOption === 'pickup' && !this.displayPickupLocation) { + return 'pickupOptions.storeIsNotSelected'; + } + + return null; + } constructor() { useFeatureStyles('a11yDeliveryMethodFieldset'); + useFeatureStyles('a11yPickupOptionsTabs'); } ngOnChanges(): void { - if (this.disableControls) { - this.pickupOptionsForm.get('pickupOption')?.disable(); + if (this.featureConfigService.isEnabled('a11yPickupOptionsTabs')) { + this.onSelectedOptionChange(); + } else { + if (this.disableControls) { + this.pickupOptionsForm.get('pickupOption')?.disable(); + } + this.pickupOptionsForm.markAllAsTouched(); + this.pickupOptionsForm.get('pickupOption')?.setValue(this.selectedOption); + } + } + + ngAfterViewInit() { + if (this.featureConfigService.isEnabled('a11yPickupOptionsTabs')) { + this.initializeTabs(); + this.subscription.add( + this.tabComponent?.openTabs$.subscribe((openTabs) => { + // open tabs should have one tab opened for mode "TAB" + const openedTab = openTabs[0]; + const selectedOption = + openedTab === PickupOptionsTabs.DELIVERY ? 'delivery' : 'pickup'; + this.onPickupOptionChange(selectedOption); + }) + ); } - this.pickupOptionsForm.markAllAsTouched(); - this.pickupOptionsForm.get('pickupOption')?.setValue(this.selectedOption); } /** Emit a new selected option. */ @@ -89,4 +137,48 @@ export class PickupOptionsComponent implements OnChanges { // Return false to stop `onPickupOptionChange` being called after this return false; } + + protected initializeTabs() { + this.tabs = [ + { + headerKey: 'pickupOptions.shipIt', + content: this.deliveryTabPanel, + id: PickupOptionsTabs.DELIVERY, + }, + { + headerKey: 'pickupOptions.pickup', + content: this.pickupTabPanel, + id: PickupOptionsTabs.PICKUP, + }, + ]; + this.tabConfig = { + label: 'pickupOptions.legend', + openTabs: [ + this.selectedOption === 'delivery' + ? PickupOptionsTabs.DELIVERY + : PickupOptionsTabs.PICKUP, + ], + }; + this.cdr.detectChanges(); + } + + protected onSelectedOptionChange() { + if (!this.tabComponent) { + return; + } + this.tabComponent.openTabs$.pipe(take(1)).subscribe((openTabs) => { + const openedTab = openTabs[0]; + const shouldBeOpened = + this.selectedOption === 'delivery' + ? PickupOptionsTabs.DELIVERY + : PickupOptionsTabs.PICKUP; + if (openedTab !== shouldBeOpened) { + this.tabComponent?.select(shouldBeOpened, TAB_MODE.TAB); + } + }); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } } diff --git a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.module.ts b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.module.ts index 5ce387548a8..35c2b48fd08 100644 --- a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.module.ts +++ b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.module.ts @@ -9,6 +9,7 @@ import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { FeaturesConfigModule, I18nModule } from '@spartacus/core'; import { PickupOptionsComponent } from './pickup-options.component'; +import { TabModule } from '@spartacus/storefront'; @NgModule({ imports: [ @@ -16,6 +17,7 @@ import { PickupOptionsComponent } from './pickup-options.component'; I18nModule, ReactiveFormsModule, FeaturesConfigModule, + TabModule, ], declarations: [PickupOptionsComponent], exports: [PickupOptionsComponent], diff --git a/feature-libs/pickup-in-store/styles/_pickup-options.scss b/feature-libs/pickup-in-store/styles/_pickup-options.scss index fae149c2b76..3af48455fc8 100644 --- a/feature-libs/pickup-in-store/styles/_pickup-options.scss +++ b/feature-libs/pickup-in-store/styles/_pickup-options.scss @@ -3,10 +3,92 @@ @include type('6'); } - @include forFeature('a11yDeliveryMethodFieldset') { - legend { - @include type('8'); - margin: 15px 0 10px 0; + @include forFeature('a11yPickupOptionsTabs') { + .cx-pickup-options { + margin-bottom: 1.5rem; + } + + .cx-pickup-options-container { + background-color: var(--cx-color-background); + border-radius: 1rem; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .cx-pickup-options-legend { + font-weight: bold; + margin: 0; + } + } + .cx-pickup-store { + &:after { + content: '|' / ''; + display: inline-block; + text-decoration: none; + margin-inline-start: 0.3rem; + margin-inline-end: 0.3rem; + } + } + + cx-tab { + --cx-tab-btn-width: 50%; + --cx-tab-btn-border: 1px var(--cx-color-light) solid; + --cx-tab-btn-border-radius: 1rem; + --cx-tab-btn-font-size: 1rem; + --cx-tab-panel-bg: var(--cx-color-inverse); + --cx-tab-panel-padding: 0.5rem 1rem; + --cx-tab-gap: 1rem; + --cx-tab-btn-bg-color: var(--cx-color-inverse); + --cx-tab-panel-margin-top: 1rem; + --cx-tab-panel-border-radius: 1rem; + --cx-tab-panel-border-top: 1px var(--cx-color-light) solid; + --cx-tab-panel-border-end: 1px var(--cx-color-light) solid; + --cx-tab-panel-border-bottom: 1px var(--cx-color-light) solid; + --cx-tab-panel-border-start: 1px var(--cx-color-light) solid; + } + + .cx-invalid-message { + font-size: 14px; + margin: 10px 0; + display: block; + padding-inline-start: 25px; + position: relative; + word-break: break-word; + + @include forFeature('a11yImproveContrast') { + @include type('7'); + } + + &::before, + &::after { + position: absolute; + left: 0; + top: 0; + bottom: 0; + margin: auto; + width: 20px; + height: 20px; + } + + &::before { + content: ''; + background-color: var(--cx-color-danger); + border-radius: 50%; + } + + &::after { + content: '!' / ''; + color: var(--cx-color-inverse); + font-weight: var(--cx-font-weight-bold); + text-align: center; + line-height: 20px; + } + + &:focus { + box-shadow: none; + -webkit-box-shadow: none; } } } 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 1609200e2f5..4550880e2cf 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 @@ -463,6 +463,16 @@ export interface FeatureTogglesInterface { */ a11yCarouselArrowKeysNavigation?: boolean; + /** + * Use tabs instead of radio group for pickup options. Improves SR narration and keyboard navigation pattern. + * Modified components: + * - `PickupOptionsComponent` + * - `PdpPickupOptionsContainerComponent` + * - `CartPickupOptionsContainerComponent` + * - `AddToCartComponent` + */ + a11yPickupOptionsTabs?: boolean; + /** * `AnonymousConsentDialogComponent` - after consent was given/withdrawn the notification * will be displayed @@ -739,6 +749,7 @@ export const defaultFeatureToggles: Required = { a11yUseButtonsForBtnLinks: false, a11yTabComponent: false, a11yCarouselArrowKeysNavigation: false, + a11yPickupOptionsTabs: false, a11yNotificationsOnConsentChange: false, a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: false, a11yFacetsDialogFocusHandling: false, diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 766e942f566..43a9b4bb4b6 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -353,6 +353,7 @@ if (environment.cpq) { a11yUseButtonsForBtnLinks: true, a11yTabComponent: true, a11yCarouselArrowKeysNavigation: true, + a11yPickupOptionsTabs: true, a11yNotificationsOnConsentChange: true, a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: true, diff --git a/projects/storefrontlib/cms-components/content/index.ts b/projects/storefrontlib/cms-components/content/index.ts index 2d8d142067c..c722152082b 100644 --- a/projects/storefrontlib/cms-components/content/index.ts +++ b/projects/storefrontlib/cms-components/content/index.ts @@ -18,3 +18,7 @@ export * from './tab-paragraph-container/tab-paragraph-container.component'; export * from './tab-paragraph-container/tab-paragraph-container.module'; export * from './video/video.component'; export * from './video/video.module'; +export * from './tab/tab.component'; +export * from './tab/tab.model'; +export * from './tab/tab.module'; +export * from './tab/panel/tab-panel.component'; diff --git a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html index 33e81bd180b..c13b924551e 100644 --- a/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html +++ b/projects/storefrontlib/cms-components/content/tab/panel/tab-panel.component.html @@ -1,10 +1,13 @@
- +
diff --git a/projects/storefrontlib/cms-components/content/tab/tab.component.html b/projects/storefrontlib/cms-components/content/tab/tab.component.html index a262e786fef..c58daacd435 100644 --- a/projects/storefrontlib/cms-components/content/tab/tab.component.html +++ b/projects/storefrontlib/cms-components/content/tab/tab.component.html @@ -7,6 +7,7 @@ >