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 ffdd7e8d3ede..b6163a2472e1 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 @@ -309,6 +309,11 @@ export interface FeatureTogglesInterface { */ a11yUnitsListKeyboardControls?: boolean; + /** + * Adds label to the `SearchBoxComponent` search input + */ + a11ySearchboxLabel?: 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, @@ -744,6 +749,7 @@ export const defaultFeatureToggles: Required = { a11yFacetKeyboardNavigation: false, a11yUnitsListKeyboardControls: true, a11yCartItemsLinksStyles: true, + a11ySearchboxLabel: false, a11yHideSelectBtnForSelectedAddrOrPayment: false, a11yFocusableCarouselControls: true, a11yUseTrapTabInsteadOfTrapInDialogs: false, diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index bf5f1d99113a..a7737fa04824 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -331,6 +331,7 @@ if (environment.cpq) { a11ySearchBoxMobileFocus: true, a11yFacetKeyboardNavigation: true, a11yUnitsListKeyboardControls: true, + a11ySearchboxLabel: true, a11yCartItemsLinksStyles: true, a11yHideSelectBtnForSelectedAddrOrPayment: true, a11yFocusableCarouselControls: true, 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 00f34cd74cc0..3ef3948ced96 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 @@ -1,4 +1,8 @@ -
+
+

- -
+
@@ -158,7 +234,7 @@

*ngIf="config.recentSearches" [cxOutlet]="searchBoxOutlets.RECENT_SEARCHES" [cxOutletContext]="{ - search: searchInput.value, + search: searchInputEl?.nativeElement.value, searchBoxActive: searchBoxActive, maxRecentSearches: config.maxRecentSearches, }" @@ -204,7 +280,7 @@

(mousedown)="preventDefault($event)" (click)=" dispatchProductEvent({ - freeText: searchInput.value, + freeText: searchInputEl?.nativeElement.value, productCode: product.code, }) " @@ -248,7 +324,7 @@

(mousedown)="preventDefault($event)" (click)=" dispatchProductEvent({ - freeText: searchInput.value, + freeText: searchInputEl?.nativeElement.value, productCode: product.code, }) " @@ -288,7 +364,7 @@

{{ 'cdsTrendingSearches.trendingSearches' | 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 ab547fbb06cb..1245d97e4388 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 @@ -1,4 +1,3 @@ -import { Component, Input, Pipe, PipeTransform } from '@angular/core'; import { ComponentFixture, fakeAsync, @@ -6,6 +5,13 @@ import { tick, waitForAsync, } from '@angular/core/testing'; +import { + Component, + Directive, + Input, + Pipe, + PipeTransform, +} from '@angular/core'; import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; @@ -34,6 +40,8 @@ import { SearchBoxSuggestionSelectedEvent, } from './search-box.events'; import { SearchResults } from './search-box.model'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; +import { OutletDirective } from '@spartacus/storefront'; const mockSearchBoxComponentData: CmsSearchBoxComponent = { uid: '001', @@ -90,6 +98,24 @@ class MockMediaComponent { @Input() alt; } +@Directive({ + selector: '[cxOutlet]', +}) +class MockOutletDirective implements Partial { + @Input() cxOutlet: string; + @Input() cxOutletContext: string; +} + +@Component({ + selector: 'cx-carousel', + template: ``, +}) +class MockCarouselComponent { + @Input() items: any; + @Input() itemWidth: any; + @Input() template: any; + @Input() hideIndicators: any; +} const mockRouterState: RouterState = { nextState: undefined, state: { @@ -168,10 +194,13 @@ describe('SearchBoxComponent', () => { ], declarations: [ SearchBoxComponent, + MockFeatureDirective, MockUrlPipe, MockHighlightPipe, MockCxIconComponent, MockMediaComponent, + MockOutletDirective, + MockCarouselComponent, ], providers: [ { @@ -256,8 +285,8 @@ describe('SearchBoxComponent', () => { }); it('should launch the search page, given it is not an empty search', () => { - const input = fixture.debugElement.query(By.css('.searchbox > input')); - + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('.searchbox input')); input.nativeElement.value = PRODUCT_SEARCH_STRING; input.triggerEventHandler('keydown.enter', {}); @@ -267,7 +296,8 @@ describe('SearchBoxComponent', () => { }); it('should not launch search page on empty search', () => { - const input = fixture.debugElement.query(By.css('.searchbox > input')); + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('.searchbox input')); input.triggerEventHandler('keydown.enter', {}); fixture.detectChanges(); @@ -307,7 +337,7 @@ describe('SearchBoxComponent', () => { selectedSuggestion: 'laptop', searchSuggestions: [{ value: 'laptop' }, { value: 'camileo' }], }; - searchBoxComponent.searchInput = { nativeElement: inputElement }; + searchBoxComponent.searchInputEl = { nativeElement: inputElement }; // Simulate typing a query searchBoxComponent.search('laptop'); @@ -363,6 +393,7 @@ describe('SearchBoxComponent', () => { describe('UI tests', () => { it('should contain an input text field', () => { + fixture.detectChanges(); expect(fixture.debugElement.query(By.css('input'))).not.toBeNull(); }); @@ -392,7 +423,7 @@ describe('SearchBoxComponent', () => { searchBoxComponent.queryText = 'something'; fixture.detectChanges(); const box = fixture.debugElement.query( - By.css('.searchbox > input') + By.css('.searchbox input') ).nativeElement; box.select(); fixture.debugElement.query(By.css('.reset')).nativeElement.click(); @@ -413,7 +444,7 @@ describe('SearchBoxComponent', () => { fixture.detectChanges(); searchBoxComponent.searchBoxActive = true; const mockSearchInput = fixture.debugElement.query( - By.css('.searchbox > input') + By.css('.searchbox input') ).nativeElement; spyOn(mockSearchInput, 'focus'); @@ -471,7 +502,8 @@ describe('SearchBoxComponent', () => { }); it('should contain chosen word from the dropdown', () => { - const input = fixture.debugElement.query(By.css('.searchbox > input')); + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('.searchbox input')); mockRouterState.state.context = { id: 'search', type: PageType.CONTENT_PAGE, @@ -485,11 +517,13 @@ describe('SearchBoxComponent', () => { }); it('should not contain searched word when navigating to another page', () => { - const input = fixture.debugElement.query(By.css('.searchbox > input')); + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('.searchbox input')); mockRouterState.state.context = null; input.nativeElement.value = PRODUCT_SEARCH_STRING; input.triggerEventHandler('keydown.enter', {}); routerState$.next(mockRouterState); + fixture.detectChanges(); expect(searchBoxComponent.chosenWord).toEqual(''); expect(input.nativeElement.value).toEqual(''); @@ -502,7 +536,7 @@ describe('SearchBoxComponent', () => { // Focus should begin on searchbox input const inputSearchBox: HTMLElement = fixture.debugElement.query( - By.css('.searchbox > input') + By.css('.searchbox input') ).nativeElement; inputSearchBox.focus(); expect(inputSearchBox).toBe(getFocusedElement()); 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 a70d9b5ad366..f37b6bf0db50 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 @@ -25,6 +25,7 @@ import { PageType, RoutingService, WindowRef, + useFeatureStyles, } from '@spartacus/core'; import { Observable, of, Subscription } from 'rxjs'; import { filter, map, switchMap, tap } from 'rxjs/operators'; @@ -96,7 +97,7 @@ export class SearchBoxComponent implements OnInit, OnDestroy { } } - @ViewChild('searchInput') searchInput: any; + @ViewChild('searchInput') searchInputEl: ElementRef; @ViewChild('searchButton') searchButton: ElementRef; @@ -105,11 +106,11 @@ export class SearchBoxComponent implements OnInit, OnDestroy { if ( (this.featureConfigService?.isEnabled('a11ySearchBoxFocusOnEscape') && this.winRef.document.activeElement !== - this.searchInput?.nativeElement) || + this.searchInputEl?.nativeElement) || this.searchBoxActive ) { setTimeout(() => { - this.searchInput.nativeElement.focus(); + this.searchInputEl.nativeElement.focus(); }); } } @@ -153,7 +154,9 @@ export class SearchBoxComponent implements OnInit, OnDestroy { protected componentData: CmsComponentData, protected winRef: WindowRef, protected routingService: RoutingService - ) {} + ) { + useFeatureStyles('a11ySearchboxLabel'); + } /** * Returns the SearchBox configuration. The configuration is driven by multiple @@ -201,7 +204,7 @@ export class SearchBoxComponent implements OnInit, OnDestroy { data.state.context?.type === PageType.CONTENT_PAGE ) ) { - this.chosenWord = ''; + this.updateChosenWord(''); } }); @@ -257,7 +260,7 @@ export class SearchBoxComponent implements OnInit, OnDestroy { true ); this.searchBoxActive = true; - this.searchInput?.nativeElement.focus(); + this.searchInputEl?.nativeElement.focus(); } } else { this.searchBoxComponentService.toggleBodyClass(SEARCHBOX_IS_ACTIVE, true); @@ -406,6 +409,9 @@ export class SearchBoxComponent implements OnInit, OnDestroy { updateChosenWord(chosenWord: string): void { this.chosenWord = chosenWord; + if (this.searchInputEl) { + this.searchInputEl.nativeElement.value = this.chosenWord; + } } protected getFocusedIndex(): number { diff --git a/projects/storefrontstyles/scss/components/product/search/_searchbox.scss b/projects/storefrontstyles/scss/components/product/search/_searchbox.scss index a9e1df613669..27b47ea14fd2 100644 --- a/projects/storefrontstyles/scss/components/product/search/_searchbox.scss +++ b/projects/storefrontstyles/scss/components/product/search/_searchbox.scss @@ -68,12 +68,35 @@ @include media-breakpoint-down(sm) { // hide the input on mobile when there's no interaction with searchbox cx-searchbox { - input { - // we cannot use display:none, visible:hidden or opacity: 0 - // as this will no longer emit a focus event to the controller logic - width: 0; - padding: 0; + // cxFeat_a11ySearchboxLabel class is only applied if a11ySearchboxLabel flag is true + // Needed to add this class manually since: + // 1. %cx-searchbox__body can't be styled with `@include forFeature('...')` + // 2. We can't apply changes for when feature flag is NOT enabled and it would be + // complicated to achieve desired behaviour without this possibility. + // TODO: When removing feature flag `a11ySearchboxLabel` next major release remove also cxFeat_a11ySearchboxLabe class + // and all styles for label:not(.cxFeat_a11ySearchboxLabel) + label:not(.cxFeat_a11ySearchboxLabel) { + input { + // we cannot use display:none, visible:hidden or opacity: 0 + // as this will no longer emit a focus event to the controller logic + width: 0; + padding: 0; + } + } + + .cxFeat_a11ySearchboxLabel { + .cx-label-inner-container, + input { + width: 0; + padding: 0; + border: none; + } + + .cx-input-label { + display: none; + } } + button.reset { display: none; } @@ -83,6 +106,7 @@ } %cx-searchbox { + --cx-mobile-header-height: 60px; @include media-breakpoint-up(md) { // we position the parent relative to ensure the result panel // is aligned to the left of searchbox @@ -101,6 +125,15 @@ } } + @include forFeature('a11ySearchboxLabel') { + > .cx-searchbox-container { + @include media-breakpoint-up(md) { + background-color: unset; + position: unset; + } + } + } + a, h3 { padding: 6px 16px; @@ -108,7 +141,20 @@ user-select: none; } - label { + @include forFeature('a11ySearchboxLabel') { + .cx-input-label { + color: var(--cx-color-text); + + @include media-breakpoint-down(sm) { + position: absolute; + top: var(--cx-mobile-header-height); + left: 10px; + z-index: 30; + } + } + } + + label:not(.cxFeat_a11ySearchboxLabel) { display: flex; align-content: stretch; margin: 0; @@ -200,6 +246,121 @@ } } + label.cxFeat_a11ySearchboxLabel { + display: flex; + align-content: stretch; + align-items: center; + margin: 0; + padding-top: 6px; + padding-inline-end: 6px; + padding-bottom: 6px; + padding-inline-start: 10px; + gap: 15px; + + @include media-breakpoint-up(md) { + // hide search icon when the input is dirty + &.dirty div.search-icon { + display: none; + } + } + + &:not(.dirty) button.reset { + display: none; + } + + .cx-label-inner-container { + display: flex; + align-content: stretch; + align-items: center; + padding-top: 6px; + padding-inline-end: 6px; + padding-bottom: 6px; + padding-inline-start: 10px; + + @include media-breakpoint-up(md) { + border: 1px solid var(--cx-color-medium); + width: 27vw; + min-width: 300px; + max-width: 550px; + background-color: var(--cx-color-inverse); + } + + @include media-breakpoint-down(sm) { + position: absolute; + left: 0; + top: var(--cx-mobile-header-height); + width: 100%; + background-color: var(--cx-color-inverse); + z-index: 20; + padding-top: 25px; + } + } + + input { + background: none; + border: none; + outline: none; + display: block; + + @include media-breakpoint-down(sm) { + width: 100%; + padding: 6px 16px; + height: 48px; + border: 1px solid var(--cx-color-medium); + border-radius: 4px; + } + + flex-basis: 100%; + height: 35px; + color: var(--cx-color-text); + z-index: 20; + + @include placeholder { + color: currentColor; + + @include forFeature('a11yImproveContrast') { + color: var(--cx-color-dark); + } + } + } + + button, + div.search-icon { + flex-basis: 48px; + text-align: center; + background: none; + border: none; + padding: 6px; + color: var(--cx-color-medium); + + @include forFeature('a11yImproveContrast') { + color: var(--cx-color-secondary); + } + + @include media-breakpoint-down(sm) { + color: var(--cx-color-primary); + font-size: var(--cx-font-size, 1.563rem); + + &.reset { + display: none; + } + } + + &.reset cx-icon { + &:before { + font-size: 1.4rem; + } + @include media-breakpoint-down(sm) { + position: relative; + left: 74px; + z-index: 20; + top: 52px; + margin-top: 0; + } + } + } + } + .results { // hide the result by default display: none; @@ -225,6 +386,10 @@ @include media-breakpoint-down(sm) { top: 120px; + + @include forFeature('a11ySearchboxLabel') { + top: calc(var(--cx-mobile-header-height) + 79px); + } z-index: 10; }