From e96611aaa412cf0e41c8156e25fe23a9adb6ae14 Mon Sep 17 00:00:00 2001 From: Stanislav Sukhanov Date: Mon, 14 Oct 2024 11:22:30 +0200 Subject: [PATCH 01/18] fix: (CXSPA-7995) add aria-expanded attribute to view-hours button (#19313) --- .../components/presentational/store/store.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/feature-libs/pickup-in-store/components/presentational/store/store.component.html b/feature-libs/pickup-in-store/components/presentational/store/store.component.html index 92178a8f2bd..08a40fd8acf 100644 --- a/feature-libs/pickup-in-store/components/presentational/store/store.component.html +++ b/feature-libs/pickup-in-store/components/presentational/store/store.component.html @@ -7,6 +7,7 @@
+ + diff --git a/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.spec.ts b/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.spec.ts index 004436cb20f..31b1788ba97 100644 --- a/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.spec.ts +++ b/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.spec.ts @@ -109,5 +109,51 @@ describe('CpqQuoteOfferComponent', () => { component.ngOnInit(); expect(component.quoteDiscountData).toBeNull(); }); + it('should calculate the correct discount percentage', () => { + const basePrice = 100; + const appliedDiscount = 20; + const quantity = 1; + const expectedPercentage = + (appliedDiscount / (basePrice * quantity)) * 100; + const result = component.getDiscountPercentage( + basePrice, + appliedDiscount, + quantity + ); + expect(result).toBe(expectedPercentage); + }); + }); + describe('formatDiscount', () => { + it('should return an empty string for undefined input', () => { + expect(component.formatDiscount(undefined)).toBe(''); + }); + + it('should return "5" for input 5', () => { + expect(component.formatDiscount(5)).toBe('5'); + }); + + it('should return "5.50" for input 5.5', () => { + expect(component.formatDiscount(5.5)).toBe('5.50'); + }); + + it('should return "5.12" for input 5.1234', () => { + expect(component.formatDiscount(5.1234)).toBe('5.12'); + }); + + it('should return "0" for input 0', () => { + expect(component.formatDiscount(0)).toBe('0'); + }); + + it('should return "-3" for input -3', () => { + expect(component.formatDiscount(-3)).toBe('-3'); + }); + + it('should return "-3.25" for input -3.25', () => { + expect(component.formatDiscount(-3.25)).toBe('-3.25'); + }); + + it('should return "1000000" for input 1000000', () => { + expect(component.formatDiscount(1000000)).toBe('1000000'); + }); }); }); diff --git a/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.ts b/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.ts index 15f7e6fd113..eaa949001f4 100644 --- a/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.ts +++ b/integration-libs/cpq-quote/cpq-quote-discount/components/cpq-quote/cpq-quote-offer.component.ts @@ -45,4 +45,28 @@ export class CpqQuoteOfferComponent implements OnInit, OnDestroy { this.subscription.unsubscribe(); } } + + getDiscountPercentage( + basePrice: number, + appliedDiscount: number | undefined, + quantity: number | undefined + ): number | undefined { + if ( + basePrice > 0 && + appliedDiscount !== undefined && + quantity !== undefined && + quantity > 0 + ) { + const totalBasePrice = basePrice * quantity; + return (appliedDiscount / totalBasePrice) * 100; + } + return undefined; + } + + formatDiscount(value: number | undefined): string { + if (value === undefined) { + return ''; + } + return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(2); + } } From 121ea4b8374ee43f882362bc06417e6f4e69f50a Mon Sep 17 00:00:00 2001 From: LarisaStar <61147963+Larisa-Staroverova@users.noreply.github.com> Date: Wed, 16 Oct 2024 07:18:20 +0200 Subject: [PATCH 09/18] fix: Console issue that appears after clicking on i-icon (#19358) Closes CXSPA-8600 --- ...or-attribute-multi-selection-image.component.html | 2 -- ...attribute-multi-selection-image.component.spec.ts | 12 ------------ ...r-attribute-single-selection-image.component.html | 2 -- ...ttribute-single-selection-image.component.spec.ts | 12 ------------ 4 files changed, 28 deletions(-) diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html index 2dfc0c4655c..d960dcfa684 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.html @@ -43,7 +43,6 @@ createAttributeValueIdForConfigurator(attribute, value.valueCode) + '-input' }}" - aria-hidden="true" class="form-check-label" > { ); }); - it("should contain label elements with class name 'form-check-label' and 'aria-hidden' attribute attribute that removes label from the accessibility tree", () => { - CommonConfiguratorTestUtilsService.expectElementContainsA11y( - expect, - htmlElem, - 'label', - 'form-check-label', - 2, - 'aria-hidden', - 'true' - ); - }); - it("should contain button elements with 'aria-label' attribute that point out that there is a description for the current value", () => { (config.features ?? {}).productConfiguratorAttributeTypesV2 = true; fixture.detectChanges(); diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html index 716fc198177..9858635f4ce 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/single-selection-image/configurator-attribute-single-selection-image.component.html @@ -60,7 +60,6 @@ createAttributeValueIdForConfigurator(attribute, value.valueCode) + '-input' }}" - aria-hidden="true" class="form-check-label form-radio-label" > { ); }); - it("should contain label element with class name 'form-check-label' and 'aria-hidden' attribute that removes an element from the accessibility tree", () => { - CommonConfiguratorTestUtilsService.expectElementContainsA11y( - expect, - htmlElem, - 'label', - 'form-check-label', - 1, - 'aria-hidden', - 'true' - ); - }); - it("should contain button elements with 'aria-label' attribute that point out that there is a description for the current value", () => { (config.features ?? {}).productConfiguratorAttributeTypesV2 = true; fixture.detectChanges(); From d9fc090d1358495fedb0d15f4f609f08bdcb4ac8 Mon Sep 17 00:00:00 2001 From: lasoh Date: Wed, 16 Oct 2024 10:37:17 +0200 Subject: [PATCH 10/18] feat: Feature/cxcds 12248 redesign search spa (#19260) Co-authored-by: Konrad Dzikowski Co-authored-by: darvsorceix Co-authored-by: github-actions[bot] Co-authored-by: Peter Kurajsky Co-authored-by: Peter Kurajsky Co-authored-by: Artur Lasocha --- .../recent-searches.component.html | 65 ++- .../trending-searches.component.html | 50 +- .../trending-searches.component.ts | 7 + .../assets/src/translations/en/common.json | 3 +- .../feature-toggles/config/feature-toggles.ts | 6 + .../spartacus/spartacus-features.module.ts | 1 + .../search-box/search-box-features.model.ts | 1 + .../search-box/search-box.component.html | 320 +++++++---- .../search-box/search-box.component.spec.ts | 369 ++++++++++-- .../search-box/search-box.component.ts | 231 +++++++- .../search-box/search-box.module.ts | 2 + .../carousel/carousel.component.html | 22 +- .../carousel/carousel.component.spec.ts | 8 +- .../components/carousel/carousel.component.ts | 16 +- .../product/carousel/_carousel.scss | 6 + .../components/product/search/_searchbox.scss | 539 ++++++++++++++++-- 16 files changed, 1366 insertions(+), 280 deletions(-) diff --git a/integration-libs/cds/src/recent-searches/recent-searches.component.html b/integration-libs/cds/src/recent-searches/recent-searches.component.html index 3aab0add992..e4e08490a91 100644 --- a/integration-libs/cds/src/recent-searches/recent-searches.component.html +++ b/integration-libs/cds/src/recent-searches/recent-searches.component.html @@ -1,35 +1,40 @@ -
- {{ 'cdsRecentSearches.recentSearches' | cxTranslate }} +
+

+ {{ 'cdsRecentSearches.recentSearches' | cxTranslate }} +

+
- diff --git a/integration-libs/cds/src/trending-searches/trending-searches.component.html b/integration-libs/cds/src/trending-searches/trending-searches.component.html index 166d28f54bf..a37cc62d339 100644 --- a/integration-libs/cds/src/trending-searches/trending-searches.component.html +++ b/integration-libs/cds/src/trending-searches/trending-searches.component.html @@ -1,26 +1,34 @@ -
- {{ 'cdsTrendingSearches.trendingSearches' | cxTranslate }} + - - diff --git a/integration-libs/cds/src/trending-searches/trending-searches.component.ts b/integration-libs/cds/src/trending-searches/trending-searches.component.ts index 9c980a6d0d1..3164f0b5092 100644 --- a/integration-libs/cds/src/trending-searches/trending-searches.component.ts +++ b/integration-libs/cds/src/trending-searches/trending-searches.component.ts @@ -60,4 +60,11 @@ export class TrendingSearchesComponent implements OnInit { get contextObservable() { return this.outletContext?.context$ ?? EMPTY; } + + shareEvent(event: KeyboardEvent) { + if (!event) { + throw new Error('Missing Event'); + } + this.searchBoxComponentService.shareEvent(event); + } } diff --git a/projects/assets/src/translations/en/common.json b/projects/assets/src/translations/en/common.json index d5cd17fb481..c08cc047a18 100644 --- a/projects/assets/src/translations/en/common.json +++ b/projects/assets/src/translations/en/common.json @@ -84,7 +84,8 @@ "noMatch": "We could not find any results", "exactMatch": "{{ term }}", "empty": "Ask us anything" - } + }, + "closeSearchPanel": "Close" }, "sorting": { "date": "Date", 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 f62c5f5198e..0e440786b05 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 @@ -61,6 +61,11 @@ export interface FeatureTogglesInterface { */ showSearchingCustomerByOrderInASM?: boolean; + /** + * New REDESIGNED search-box component + */ + searchBoxV2?: boolean; + /** * Some Changes for input of cart Number and text of Customer360View in ASM view */ @@ -638,6 +643,7 @@ export const defaultFeatureToggles: Required = { showBillingAddressInDigitalPayments: false, showDownloadProposalButton: false, showPromotionsInPDP: true, + searchBoxV2: false, recentSearches: true, trendingSearches: false, pdfInvoicesSortByInvoiceDate: true, diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 0b59cf789d5..49b896e9cdc 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -293,6 +293,7 @@ if (environment.cpq) { showBillingAddressInDigitalPayments: false, showDownloadProposalButton: false, showPromotionsInPDP: false, + searchBoxV2: false, recentSearches: true, trendingSearches: false, pdfInvoicesSortByInvoiceDate: true, diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box-features.model.ts b/projects/storefrontlib/cms-components/navigation/search-box/search-box-features.model.ts index 3f7b7d02ec3..c561732343b 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box-features.model.ts +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box-features.model.ts @@ -5,6 +5,7 @@ */ export enum SearchBoxFeatures { + SEARCH_BOX_V2 = 'searchBoxV2', RECENT_SEARCHES_FEATURE = 'recentSearches', TRENDING_SEARCHES_FEATURE = 'trendingSearches', } diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.html b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.html index 7e2acbca3cc..54628734857 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.html +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.html @@ -64,51 +64,93 @@ id="results" (click)="close($any($event), true)" role="dialog" + [class.no-headers]=" + !isEnabledFeature(searchBoxFeatures.RECENT_SEARCHES_FEATURE) && + !isEnabledFeature(searchBoxFeatures.TRENDING_SEARCHES_FEATURE) && + !isEnabledFeature(searchBoxFeatures.SEARCH_BOX_V2) + " > + +

+ + +
+ +

+ {{ 'searchBox.suggestions' | cxTranslate }} +

+
+ +
+ +
- - -
- {{ 'searchBox.suggestions' | cxTranslate }} -
-
- + + + + + +
@@ -123,65 +165,149 @@ > - - - - + +
+ +

+ {{ 'searchBox.products' | cxTranslate }} +

- + + + + - - -
- {{ 'searchBox.products' | cxTranslate }} -
-
-
+ +
-
  • - + +

    + {{ 'searchBox.suggestions' | cxTranslate }} +

    +

    - -
    - {{ product.price?.formattedValue }} -
    -

  • - + {{ 'cdsTrendingSearches.trendingSearches' | cxTranslate }} + +

    + {{ 'cdsRecentSearches.recentSearches' | cxTranslate }} +

    +

    + {{ 'searchBox.products' | cxTranslate }} +

    + + +
    + {{ 'searchBox.initialDescription' | cxTranslate }} diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.spec.ts b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.spec.ts index 2fd11d5de96..ab547fbb06c 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.spec.ts +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.spec.ts @@ -18,7 +18,14 @@ import { RouterState, RoutingService, } from '@spartacus/core'; -import { BehaviorSubject, EMPTY, Observable, ReplaySubject, of } from 'rxjs'; +import { + BehaviorSubject, + EMPTY, + Observable, + ReplaySubject, + of, + delay, +} from 'rxjs'; import { CmsComponentData } from '../../../cms-structure/page/model/cms-component-data'; import { SearchBoxComponentService } from './search-box-component.service'; import { SearchBoxComponent } from './search-box.component'; @@ -129,8 +136,8 @@ describe('SearchBoxComponent', () => { sharedEvent = new ReplaySubject(); launchSearchPage = jasmine.createSpy('launchSearchPage'); - getResults = jasmine.createSpy('search').and.callFake(() => - of({ + getResults = jasmine.createSpy('search').and.callFake(() => { + const results = { suggestions: ['te', 'test'], message: 'I found stuff for you!', products: [ @@ -138,8 +145,9 @@ describe('SearchBoxComponent', () => { name: 'title 1', }, ], - }) - ); + }; + return of(results); + }); dispatchSuggestionSelectedEvent = jasmine.createSpy( 'dispatchSuggestionSelectedEvent' ); @@ -216,12 +224,31 @@ describe('SearchBoxComponent', () => { expect(searchBoxComponent).toBeTruthy(); }); + it('should initialize subscriptions on initialization', () => { + spyOn(searchBoxComponent['subscriptions'], 'add'); + spyOn(serviceSpy['chosenWord'], 'subscribe'); + spyOn(serviceSpy['sharedEvent'], 'subscribe'); + + searchBoxComponent.ngOnInit(); + + expect(routingService.getRouterState).toHaveBeenCalled(); + expect(serviceSpy.chosenWord.subscribe).toHaveBeenCalled(); + expect(serviceSpy.sharedEvent.subscribe).toHaveBeenCalled(); + expect(searchBoxComponent['subscriptions'].add).toHaveBeenCalledTimes(3); + }); + it('should dispatch new results when search is executed', () => { searchBoxComponent.search('testQuery'); fixture.detectChanges(); expect(serviceSpy.getResults).toHaveBeenCalled(); }); + it('should set the queryText and trigger a search', () => { + searchBoxComponent.queryText = 'testQuery'; + expect(searchBoxComponent.chosenWord).toBe('testQuery'); + expect(searchBoxComponent.search).toHaveBeenCalledWith('testQuery'); + }); + it('should dispatch new search query on input', () => { searchBoxComponent.queryText = 'test input'; fixture.detectChanges(); @@ -248,6 +275,92 @@ describe('SearchBoxComponent', () => { expect(serviceSpy.launchSearchPage).not.toHaveBeenCalled(); }); + it('should return true when the feature is enabled', () => { + spyOn( + searchBoxComponent.featureConfigService, + 'isEnabled' + ).and.returnValue(true); + expect(searchBoxComponent.searchBoxV2).toBeTrue(); + }); + + it('should return false when the feature is disabled', function () { + spyOn( + searchBoxComponent.featureConfigService, + 'isEnabled' + ).and.returnValue(false); + expect(searchBoxComponent.searchBoxV2).toBeFalse(); + }); + + it('should bind the "search-box-v2" class when the feature is enabled', function () { + spyOn( + searchBoxComponent.featureConfigService, + 'isEnabled' + ).and.returnValue(true); + expect(searchBoxComponent.searchBoxV2).toBeTrue(); + }); + + it('should handle typing, selecting suggestion, and pressing Enter to launch search', () => { + spyOn(searchBoxComponent, 'launchSearchResult').and.callThrough(); + const inputElement = document.createElement('input'); + const mockEventData: SearchBoxSuggestionSelectedEvent = { + freeText: 'laptop', + selectedSuggestion: 'laptop', + searchSuggestions: [{ value: 'laptop' }, { value: 'camileo' }], + }; + searchBoxComponent.searchInput = { nativeElement: inputElement }; + // Simulate typing a query + searchBoxComponent.search('laptop'); + + // Simulate selecting a suggestion + const suggestionEvent = new KeyboardEvent('keydown', { code: 'Enter' }); + searchBoxComponent.dispatchSuggestionEvent(mockEventData); + + // Simulate pressing Enter + searchBoxComponent.launchSearchResult(suggestionEvent, 'laptop'); + expect(searchBoxComponent.launchSearchResult).toHaveBeenCalledWith( + suggestionEvent, + 'laptop' + ); + }); + + it('should handle async search result fetching and update the results', fakeAsync(() => { + const mockResults = { + products: [{ name: 'Product 1' }, { name: 'Product 2' }], + }; + serviceSpy.getResults = jasmine + .createSpy() + .and.returnValue(of(mockResults).pipe(delay(1000))); + + let results: any; + searchBoxComponent.results$.subscribe((res) => (results = res)); + + expect(results).toBeUndefined(); // Initially no results + tick(1000); // Simulate the passage of time for async call + expect(results.products.length).toBe(2); // Results are fetched after delay + })); + + it('should use setTimeout to delay focus action', () => { + spyOn(window, 'setTimeout'); + searchBoxComponent.onEscape(); + expect(setTimeout).toHaveBeenCalled(); + }); + + it('should return an Observable when breakpointService is available', () => { + const result = searchBoxComponent.isMobile; + expect(result).toBeInstanceOf(Observable); + }); + + it('should return 0 when isMobile is false', () => { + const result = searchBoxComponent.getTabIndex(false); + expect(result).toBe(0); + }); + + it('should return 0 when isMobile is true and searchBoxActive is true', () => { + searchBoxComponent.searchBoxActive = true; + const result = searchBoxComponent.getTabIndex(true); + expect(result).toBe(0); + }); + describe('UI tests', () => { it('should contain an input text field', () => { expect(fixture.debugElement.query(By.css('input'))).not.toBeNull(); @@ -264,20 +377,11 @@ describe('SearchBoxComponent', () => { expect(fixture.debugElement.query(By.css('.results'))).toBeTruthy(); })); - it('should contain 2 suggestion after search', () => { - searchBoxComponent.queryText = 'te'; - fixture.detectChanges(); - - expect( - fixture.debugElement.queryAll(By.css('.suggestions a')).length - ).toEqual(2); - }); - it('should contain a message after search', () => { searchBoxComponent.queryText = 'te'; fixture.detectChanges(); - const el = fixture.debugElement.query(By.css('.results .message')); + const el = fixture.debugElement.query(By.css('.results h3')); expect(el).toBeTruthy(); expect((el.nativeElement).innerText).toEqual( 'I found stuff for you!' @@ -318,6 +422,26 @@ describe('SearchBoxComponent', () => { expect(mockSearchInput.focus).toHaveBeenCalled(); })); + + it('should navigate between groups and results with arrow keys', () => { + const eventDown = new KeyboardEvent('keydown', { code: 'ArrowDown' }); + const eventUp = new KeyboardEvent('keydown', { code: 'ArrowUp' }); + + spyOn(searchBoxComponent, 'focusNextChild').and.callThrough(); + spyOn(searchBoxComponent, 'focusPreviousChild').and.callThrough(); + + // Simulate navigating down + searchBoxComponent['propagateEvent'](eventDown); + expect(searchBoxComponent.focusNextChild).toHaveBeenCalledWith( + eventDown + ); + + // Simulate navigating up + searchBoxComponent['propagateEvent'](eventUp); + expect(searchBoxComponent.focusPreviousChild).toHaveBeenCalledWith( + eventUp + ); + }); }); it('should contain 1 product after search', () => { @@ -384,46 +508,183 @@ describe('SearchBoxComponent', () => { expect(inputSearchBox).toBe(getFocusedElement()); }); - it('should navigate to first child', () => { - searchBoxComponent.focusNextChild(new UIEvent('keydown.arrowdown')); - - expect( - fixture.debugElement.query(By.css('.results .suggestions > li > a')) - .nativeElement - ).toBe(getFocusedElement()); - }); - - it('should navigate to second child', () => { - searchBoxComponent.focusNextChild(new UIEvent('keydown.arrowdown')); - searchBoxComponent.focusNextChild(new UIEvent('keydown.arrowdown')); - - expect( - fixture.debugElement.query( - By.css('.results .suggestions > li:nth-child(2) > a') - ).nativeElement - ).toBe(getFocusedElement()); + describe('focusPreviousGroup', () => { + it('should prevent default key scrolling', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + + // Create a mock element with a focus method + const mockElement = jasmine.createSpyObj('HTMLDivElement', ['focus']); + + // Mock getGroupElements to return arrays with mock elements + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + [mockElement], + ['element2'], + ]); + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(1); + + searchBoxComponent.focusPreviousGroup(mockEvent); + + // Check that focus was called on the mock element + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockElement.focus).toHaveBeenCalled(); + }); + + it('should not change focus if there are no groups', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue( + [] + ); // No groups + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); + + const result = searchBoxComponent.focusPreviousGroup(mockEvent); + + expect(result).toBeUndefined(); // Should return early + }); + + it('should not change focus if current group is empty', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + [], + ['element2'], + ]); // First group is empty + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); + + const result = searchBoxComponent.focusPreviousGroup(mockEvent); + + expect(result).toBeUndefined(); // Should return early + }); + + it('should focus on the previous group if valid', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + const mockElement = jasmine.createSpyObj('HTMLDivElement', ['focus']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + [mockElement], + ['element2'], + ]); + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(1); + + searchBoxComponent.focusPreviousGroup(mockEvent); + + expect(mockElement.focus).toHaveBeenCalled(); // Focus on the first element of the previous group + }); + + it('should focus on the first group when current group is the first', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + const mockElement = jasmine.createSpyObj('HTMLDivElement', ['focus']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + [mockElement], + ['element2'], + ]); + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); + + searchBoxComponent.focusPreviousGroup(mockEvent); + + expect(mockElement.focus).toHaveBeenCalled(); // Focus on the first element of the first group + }); }); - - it('should navigate to last child', () => { - searchBoxComponent.focusPreviousChild(new UIEvent('keydown.arrowup')); - - expect( - fixture.debugElement.query( - By.css('.results .products > li > a:last-child') - ).nativeElement - ).toBe(getFocusedElement()); - }); - - it('should navigate to second last child', () => { - searchBoxComponent.focusPreviousChild(new UIEvent('keydown.arrowup')); - searchBoxComponent.focusPreviousChild(new UIEvent('keydown.arrowup')); - fixture.detectChanges(); - - expect( - fixture.debugElement.query( - By.css('.results .suggestions > li:nth-child(2) > a') - ).nativeElement - ).toBe(getFocusedElement()); + describe('focusNextGroup', () => { + it('should prevent default key scrolling', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + + // Create a mock element with a focus method + const mockElement = jasmine.createSpyObj('HTMLDivElement', ['focus']); + + // Mock getGroupElements to return arrays with mock elements + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + ['element1'], + [mockElement], + ]); + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); // First group focused + + searchBoxComponent.focusNextGroup(mockEvent); + + // Check that the default event was prevented and focus was called on the next element + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockElement.focus).toHaveBeenCalled(); // Focus on the first element of the next group + }); + + it('should not change focus if there are no groups', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue( + [] + ); // No groups + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); + + const result = searchBoxComponent.focusNextGroup(mockEvent); + + expect(result).toBeUndefined(); // Should return early + }); + + it('should not change focus if all groups are empty', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + [], + [], + ]); // Both groups are empty + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); + + const result = searchBoxComponent.focusNextGroup(mockEvent); + + expect(result).toBeUndefined(); // Should return early + }); + + it('should focus on the next group if valid', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + const mockElement = jasmine.createSpyObj('HTMLDivElement', ['focus']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + ['element1'], + [mockElement], + ]); + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(0); + + searchBoxComponent.focusNextGroup(mockEvent); + + expect(mockElement.focus).toHaveBeenCalled(); // Focus on the first element of the next group + }); + + it('should wrap around and focus on the first group if last group is focused', () => { + const mockEvent = jasmine.createSpyObj('UIEvent', ['preventDefault']); + const mockElement = jasmine.createSpyObj('HTMLDivElement', ['focus']); + spyOn(searchBoxComponent, 'getGroupElements').and.returnValue([ + [mockElement], + ['element2'], + ]); + spyOn( + searchBoxComponent, + 'getFocusedGroupIndex' + ).and.returnValue(1); // Last group + + searchBoxComponent.focusNextGroup(mockEvent); + + expect(mockElement.focus).toHaveBeenCalled(); // Focus on the first element of the first group + }); }); }); diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts index ff886fe4209..a70d9b5ad36 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts @@ -9,13 +9,15 @@ import { ChangeDetectorRef, Component, ElementRef, + HostBinding, HostListener, + inject, Input, OnDestroy, OnInit, Optional, + Renderer2, ViewChild, - inject, } from '@angular/core'; import { CmsSearchBoxComponent, @@ -24,7 +26,7 @@ import { RoutingService, WindowRef, } from '@spartacus/core'; -import { Observable, Subscription, of } from 'rxjs'; +import { Observable, of, Subscription } from 'rxjs'; import { filter, map, switchMap, tap } from 'rxjs/operators'; import { ICON_TYPE } from '../../../cms-components/misc/icon/index'; import { CmsComponentData } from '../../../cms-structure/page/model/cms-component-data'; @@ -58,6 +60,8 @@ const SEARCHBOX_IS_ACTIVE = 'searchbox-is-active'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SearchBoxComponent implements OnInit, OnDestroy { + private elementRef = inject(ElementRef); + private renderer = inject(Renderer2); readonly searchBoxOutlets = SearchBoxOutlets; readonly searchBoxFeatures = SearchBoxFeatures; @Input() config: SearchBoxConfig; @@ -68,10 +72,30 @@ export class SearchBoxComponent implements OnInit, OnDestroy { @Input('queryText') set queryText(value: string) { if (value) { + this.updateChosenWord(value); this.search(value); } } + @HostBinding('class.search-box-v2') get searchBoxV2() { + return this.isEnabledFeature(SearchBoxFeatures.SEARCH_BOX_V2); + } + + get hasSearchBoxV2(): boolean { + const hostElement = this.elementRef.nativeElement; + return hostElement.classList.contains('search-box-v2'); + } + + /** + * Listener for clickout out of searchInput and searchPanel + * */ + @HostListener('document:click', ['$event']) + clickout(event: UIEvent) { + if (!this.elementRef.nativeElement.contains(event.target)) { + this.softClose(); + } + } + @ViewChild('searchInput') searchInput: any; @ViewChild('searchButton') searchButton: ElementRef; @@ -81,7 +105,7 @@ export class SearchBoxComponent implements OnInit, OnDestroy { if ( (this.featureConfigService?.isEnabled('a11ySearchBoxFocusOnEscape') && this.winRef.document.activeElement !== - this.searchInput.nativeElement) || + this.searchInput?.nativeElement) || this.searchBoxActive ) { setTimeout(() => { @@ -111,9 +135,7 @@ export class SearchBoxComponent implements OnInit, OnDestroy { // TODO: (CXSPA-6929) - Remove getter next major release. /** Temporary getter, not ment for public use */ get a11ySearchBoxMobileFocusEnabled(): boolean { - return ( - this.featureConfigService?.isEnabled('a11ySearchBoxMobileFocus') || false - ); + return this.isEnabledFeature('a11ySearchBoxMobileFocus') || false; } // TODO: (CXSPA-6929) - Make dependencies no longer optional next major release @@ -164,6 +186,10 @@ export class SearchBoxComponent implements OnInit, OnDestroy { switchMap((config) => this.searchBoxComponentService.getResults(config)) ); + items$: Observable = this.results$.pipe( + map((result) => result.products?.map((prod) => of(prod))) + ); + ngOnInit(): void { const routeStateSubscription = this.routingService .getRouterState() @@ -213,6 +239,8 @@ export class SearchBoxComponent implements OnInit, OnDestroy { */ search(query: string): void { this.searchBoxComponentService.search(query, this.config); + + this.checkOuterResults(); // force the searchBox to open this.open(); } @@ -267,7 +295,13 @@ export class SearchBoxComponent implements OnInit, OnDestroy { }); } + softClose(): void { + this.searchBoxComponentService.toggleBodyClass(SEARCHBOX_IS_ACTIVE, false); + this.searchBoxActive = false; + } + protected blurSearchBox(event: UIEvent): void { + this.softClose(); this.searchBoxComponentService.toggleBodyClass(SEARCHBOX_IS_ACTIVE, false); this.searchBoxActive = false; // TODO: (CXSPA-6929) - Remove feature flag next major release @@ -290,6 +324,19 @@ export class SearchBoxComponent implements OnInit, OnDestroy { ); } + protected checkOuterResults() { + const recentSearches = this.elementRef.nativeElement.querySelector( + 'cx-recent-searches .recent-searches' + ); + const trendingSearches = this.elementRef.nativeElement.querySelector( + 'cx-trending-searches .trending-searches' + ); + const results = this.elementRef.nativeElement.querySelector('.results'); + if (recentSearches || trendingSearches) { + this.renderer.addClass(results, 'has-outer-results'); + } + } + /** * Especially in mobile we do not want the search icon * to focus the input again when it's already open. @@ -302,16 +349,58 @@ export class SearchBoxComponent implements OnInit, OnDestroy { } // Return result list as HTMLElement array - private getResultElements(): HTMLElement[] { + protected getResultElements(): HTMLElement[] { return Array.from( this.winRef.document.querySelectorAll( - '.products > li a, .suggestions > li a, .recent-searches > li a' + '.products ul:not(.hidden) > li a, .suggestions ul > li a, .recent-searches ul > li a,.trending-searches ul > li a, .carousel-panel .item.active > a, .products .carousel-panel > button:not([disabled])' ) ); } + // Return group list as HTMLElement array + private getGroupElements(): HTMLElement[][] { + const groups: HTMLElement[][] = []; + groups.push( + Array.from( + this.winRef.document.querySelectorAll( + '.products ul:not(.hidden) > li a' + ) + ) + ); + groups.push( + Array.from( + this.winRef.document.querySelectorAll('.suggestions ul > li a') + ) + ); + groups.push( + Array.from( + this.winRef.document.querySelectorAll( + '.trending-searches-container.d-block .trending-searches ul > li a' + ) + ) + ); + groups.push( + Array.from( + this.winRef.document.querySelectorAll('.recent-searches ul > li a') + ) + ); + + groups.push( + Array.from( + this.winRef.document.querySelectorAll( + '.carousel-panel .item.active > a, .carousel-panel > button:not([disabled])' + ) + ) + ); + groups.push( + Array.from( + this.winRef.document.querySelectorAll('.search-panel-close-btn') + ) + ); + return groups.filter((group) => group.length); + } // Return focused element as HTMLElement - private getFocusedElement(): HTMLElement { + protected getFocusedElement(): HTMLElement { return this.winRef.document.activeElement; } @@ -319,11 +408,19 @@ export class SearchBoxComponent implements OnInit, OnDestroy { this.chosenWord = chosenWord; } - private getFocusedIndex(): number { + protected getFocusedIndex(): number { return this.getResultElements().indexOf(this.getFocusedElement()); } - private propagateEvent(event: KeyboardEvent) { + protected getFocusedGroupIndex(): number { + return ( + this.getGroupElements().findIndex( + (group) => group.indexOf(this.getFocusedElement()) !== -1 + ) ?? 0 + ); + } + + protected propagateEvent(event: KeyboardEvent) { if (event.code) { switch (event.code) { case 'Escape': @@ -336,6 +433,12 @@ export class SearchBoxComponent implements OnInit, OnDestroy { case 'ArrowDown': this.focusNextChild(event); return; + case 'ArrowLeft': + this.focusPreviousGroup(event); + return; + case 'ArrowRight': + this.focusNextGroup(event); + return; default: return; } @@ -379,6 +482,108 @@ export class SearchBoxComponent implements OnInit, OnDestroy { } } + // Focus on previous item in results list + focusPreviousGroup(event: UIEvent) { + event.preventDefault(); // Prevent default key scrolling behavior + + const results = this.getGroupElements(); // Get all group elements + const focusedGroupIndex = this.getFocusedGroupIndex(); // Get the currently focused group index + + // Check if there are any groups and if the focused index is valid + if ( + !results.length || + focusedGroupIndex < 0 || + focusedGroupIndex >= results.length + ) { + return; // Exit if no groups or invalid focused index + } + + // Check if the current group contains any elements + const currentGroup = results[focusedGroupIndex]; + if (currentGroup.length === 0) { + return; // If the current group is empty, exit the function + } + + // Set focus on the appropriate group + const previousGroupIndex = + focusedGroupIndex > 0 ? focusedGroupIndex - 1 : 0; + const previousGroup = results[previousGroupIndex]; + + // Check if the previous group contains any elements + if (previousGroup.length > 0) { + previousGroup[0].focus(); // Focus on the first element of the previous group + } + } + + // Focus on next item in results list + focusNextGroup(event: UIEvent) { + this.open(); // Ensure the dropdown or UI is open before navigating + event.preventDefault(); // Prevent default key scrolling behavior + + const results = this.getGroupElements(); // Get all group elements + const focusedGroupIndex = this.getFocusedGroupIndex(); // Get the current focused group index + + // Check if there are any groups and if the focused index is valid + if ( + !results.length || + focusedGroupIndex < 0 || + focusedGroupIndex >= results.length + ) { + return; // Exit if no groups or invalid focused index + } + + // Find the next group that contains elements + let nextGroupIndex = focusedGroupIndex + 1; + + // Loop forward through groups until a non-empty group is found + while ( + nextGroupIndex < results.length && + results[nextGroupIndex].length === 0 + ) { + nextGroupIndex++; // Keep moving to the next group if current one is empty + } + + // If no next group with elements was found, wrap around to the first group + if (nextGroupIndex >= results.length) { + nextGroupIndex = 0; // Move focus to the first group + if (results[nextGroupIndex].length === 0) { + return; // Exit if the first group is also empty + } + } + + // Set focus on the first element of the next (or first) non-empty group + results[nextGroupIndex][0].focus(); + } + + carouselEventPropagator(event: KeyboardEvent | null) { + if (!event || !event?.code) { + return; + } + switch (event.code) { + case 'ArrowRight': + this.focusNextChild(event); + return; + case 'ArrowLeft': { + this.getGroupElements().forEach((group) => { + if (group.indexOf(this.getFocusedElement()) !== -1) { + if (group.indexOf(this.getFocusedElement()) === 0) { + this.focusPreviousGroup(event); + } else { + this.focusPreviousChild(event); + } + } + }); + + return; + } + case 'ArrowUp': + this.focusNextGroup(event); + return; + default: + return; + } + } + /** * Opens the PLP with the given query. * @@ -419,6 +624,10 @@ export class SearchBoxComponent implements OnInit, OnDestroy { }); } + isEnabledFeature(feature: string) { + return this.featureConfigService?.isEnabled(feature); + } + ngOnDestroy(): void { this.subscriptions?.unsubscribe(); } diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts b/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts index 223bec55310..041079bcfcf 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts @@ -19,6 +19,7 @@ import { MediaModule } from '../../../shared/components/media/media.module'; import { IconModule } from '../../misc/icon/icon.module'; import { HighlightPipe } from './highlight.pipe'; import { SearchBoxComponent } from './search-box.component'; +import { CarouselModule } from '../../../shared'; @NgModule({ imports: [ @@ -30,6 +31,7 @@ import { SearchBoxComponent } from './search-box.component'; I18nModule, OutletModule, FeaturesConfigModule, + CarouselModule, ], providers: [ provideDefaultConfig({ diff --git a/projects/storefrontlib/shared/components/carousel/carousel.component.html b/projects/storefrontlib/shared/components/carousel/carousel.component.html index 90731e25f08..74fcf0e9779 100644 --- a/projects/storefrontlib/shared/components/carousel/carousel.component.html +++ b/projects/storefrontlib/shared/components/carousel/carousel.component.html @@ -6,10 +6,17 @@

    {{ title }}

    @@ -18,7 +25,7 @@

    {{ title }}

    @@ -73,7 +85,7 @@

    {{ title }}