From a500a6816d3ba63b9dd0763e2adf94b408b01096 Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Wed, 15 May 2024 13:13:17 -0400 Subject: [PATCH] feat(Commerce Atomic): add `atomic-commerce-facet` (#3935) * Add new atomic-commerce-facet component * Following slack discussion: Went with the decision to not implement the `FacetConditionsManager`. --------- Co-authored-by: Olivier Lamothe Co-authored-by: GitHub Actions Bot <> Co-authored-by: Frederic Beaudoin Co-authored-by: Louis Bompart --- packages/atomic/src/components.d.ts | 31 +- .../atomic-commerce-facet.pcss | 6 + .../atomic-commerce-facet.tsx | 353 ++++++++++++++++++ .../facets/headless-core-commerce-facet.ts | 2 +- 4 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.pcss create mode 100644 packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.tsx diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index b665194f145..c88de08a922 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -6,7 +6,7 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { AutomaticFacet, CategoryFacetSortCriterion, FacetResultsMustMatch, FacetSortCriterion, FoldedResult, GeneratedAnswer, GeneratedAnswerCitation, GeneratedAnswerStyle, InlineLink, InteractiveCitation, InteractiveResult, LogLevel as LogLevel1, PlatformEnvironment as PlatformEnvironment2, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition, SearchEngine, SearchStatus } from "@coveo/headless"; -import { CommerceEngine, InteractiveProduct, LogLevel, PlatformEnvironment, Product, ProductTemplate, ProductTemplateCondition } from "@coveo/headless/commerce"; +import { CommerceEngine, InteractiveProduct, LogLevel, PlatformEnvironment, Product, ProductTemplate, ProductTemplateCondition, RegularFacet } from "@coveo/headless/commerce"; import { i18n } from "i18next"; import { CommerceInitializationOptions } from "./components/commerce/atomic-commerce-interface/atomic-commerce-interface"; import { StandaloneSearchBoxData } from "./utils/local-storage-utils"; @@ -33,7 +33,7 @@ import { Bindings } from "./components/search/atomic-search-interface/atomic-sea import { AriaLabelGenerator as AriaLabelGenerator1 } from "./components/search/search-box-suggestions/atomic-search-box-instant-results/atomic-search-box-instant-results"; import { InitializationOptions } from "./components/search/atomic-search-interface/atomic-search-interface"; export { AutomaticFacet, CategoryFacetSortCriterion, FacetResultsMustMatch, FacetSortCriterion, FoldedResult, GeneratedAnswer, GeneratedAnswerCitation, GeneratedAnswerStyle, InlineLink, InteractiveCitation, InteractiveResult, LogLevel as LogLevel1, PlatformEnvironment as PlatformEnvironment2, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition, SearchEngine, SearchStatus } from "@coveo/headless"; -export { CommerceEngine, InteractiveProduct, LogLevel, PlatformEnvironment, Product, ProductTemplate, ProductTemplateCondition } from "@coveo/headless/commerce"; +export { CommerceEngine, InteractiveProduct, LogLevel, PlatformEnvironment, Product, ProductTemplate, ProductTemplateCondition, RegularFacet } from "@coveo/headless/commerce"; export { i18n } from "i18next"; export { CommerceInitializationOptions } from "./components/commerce/atomic-commerce-interface/atomic-commerce-interface"; export { StandaloneSearchBoxData } from "./utils/local-storage-utils"; @@ -259,6 +259,12 @@ export namespace Components { */ "withSearch": boolean; } + /** + * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + */ + interface AtomicCommerceFacet { + "facet": RegularFacet; + } /** * The `atomic-commerce-facets` component automatically renders commerce facets based on the Commerce API response. * Unlike regular facets, which require explicit definition and request in the query, the `atomic-commerce-facets` component dynamically generates facets. @@ -3184,6 +3190,15 @@ declare global { prototype: HTMLAtomicColorFacetElement; new (): HTMLAtomicColorFacetElement; }; + /** + * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + */ + interface HTMLAtomicCommerceFacetElement extends Components.AtomicCommerceFacet, HTMLStencilElement { + } + var HTMLAtomicCommerceFacetElement: { + prototype: HTMLAtomicCommerceFacetElement; + new (): HTMLAtomicCommerceFacetElement; + }; /** * The `atomic-commerce-facets` component automatically renders commerce facets based on the Commerce API response. * Unlike regular facets, which require explicit definition and request in the query, the `atomic-commerce-facets` component dynamically generates facets. @@ -4851,6 +4866,7 @@ declare global { "atomic-category-facet": HTMLAtomicCategoryFacetElement; "atomic-citation": HTMLAtomicCitationElement; "atomic-color-facet": HTMLAtomicColorFacetElement; + "atomic-commerce-facet": HTMLAtomicCommerceFacetElement; "atomic-commerce-facets": HTMLAtomicCommerceFacetsElement; "atomic-commerce-interface": HTMLAtomicCommerceInterfaceElement; "atomic-commerce-layout": HTMLAtomicCommerceLayoutElement; @@ -5208,6 +5224,12 @@ declare namespace LocalJSX { */ "withSearch"?: boolean; } + /** + * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + */ + interface AtomicCommerceFacet { + "facet": RegularFacet; + } /** * The `atomic-commerce-facets` component automatically renders commerce facets based on the Commerce API response. * Unlike regular facets, which require explicit definition and request in the query, the `atomic-commerce-facets` component dynamically generates facets. @@ -7844,6 +7866,7 @@ declare namespace LocalJSX { "atomic-category-facet": AtomicCategoryFacet; "atomic-citation": AtomicCitation; "atomic-color-facet": AtomicColorFacet; + "atomic-commerce-facet": AtomicCommerceFacet; "atomic-commerce-facets": AtomicCommerceFacets; "atomic-commerce-interface": AtomicCommerceInterface; "atomic-commerce-layout": AtomicCommerceLayout; @@ -8045,6 +8068,10 @@ declare module "@stencil/core" { * An `atomic-color-facet` displays a facet of the results for the current query as colors. */ "atomic-color-facet": LocalJSX.AtomicColorFacet & JSXBase.HTMLAttributes; + /** + * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + */ + "atomic-commerce-facet": LocalJSX.AtomicCommerceFacet & JSXBase.HTMLAttributes; /** * The `atomic-commerce-facets` component automatically renders commerce facets based on the Commerce API response. * Unlike regular facets, which require explicit definition and request in the query, the `atomic-commerce-facets` component dynamically generates facets. diff --git a/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.pcss b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.pcss new file mode 100644 index 00000000000..372f00a49fc --- /dev/null +++ b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.pcss @@ -0,0 +1,6 @@ +@import '../../../../global/global.pcss'; +@import '../../../common/facets/facet-search/facet-search.pcss'; +@import '../../../common/facets/facet-common.pcss'; +@import '../../../common/facets/facet-value-checkbox/facet-value-checkbox.pcss'; +@import '../../../common/facets/facet-value-exclude/facet-value-exclude.pcss'; +@import '../../../common/facets/facet-value-box/facet-value-box.pcss'; diff --git a/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.tsx b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.tsx new file mode 100644 index 00000000000..75464d2f9d4 --- /dev/null +++ b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.tsx @@ -0,0 +1,353 @@ +import { + RegularFacet, + RegularFacetState, + buildListingSummary, + buildSearchSummary, + SearchSummary, + ListingSummary, +} from '@coveo/headless/commerce'; +import { + Component, + h, + State, + Prop, + Element, + VNode, + Fragment, +} from '@stencil/core'; +import { + AriaLiveRegion, + FocusTargetController, +} from '../../../../utils/accessibility-utils'; +import { + BindStateToController, + InitializableComponent, + InitializeBindings, +} from '../../../../utils/initialization-utils'; +import {FacetInfo} from '../../../common/facets/facet-common-store'; +import {FacetContainer} from '../../../common/facets/facet-container/facet-container'; +import {FacetGuard} from '../../../common/facets/facet-guard'; +import {FacetHeader} from '../../../common/facets/facet-header/facet-header'; +import {FacetPlaceholder} from '../../../common/facets/facet-placeholder/facet-placeholder'; +import {announceFacetSearchResultsWithAriaLive} from '../../../common/facets/facet-search/facet-search-aria-live'; +import {FacetSearchInput} from '../../../common/facets/facet-search/facet-search-input'; +import {FacetSearchInputGuard} from '../../../common/facets/facet-search/facet-search-input-guard'; +import {FacetSearchMatches} from '../../../common/facets/facet-search/facet-search-matches'; +import { + shouldDisplaySearchResults, + shouldUpdateFacetSearchComponent, +} from '../../../common/facets/facet-search/facet-search-utils'; +import {FacetSearchValue} from '../../../common/facets/facet-search/facet-search-value'; +import {FacetShowMoreLess} from '../../../common/facets/facet-show-more-less/facet-show-more-less'; +import { + FacetValueProps, + FacetValue, +} from '../../../common/facets/facet-value/facet-value'; +import {FacetValuesGroup} from '../../../common/facets/facet-values-group/facet-values-group'; +import {initializePopover} from '../../../search/facets/atomic-popover/popover-type'; +import {CommerceBindings as Bindings} from '../../atomic-commerce-interface/atomic-commerce-interface'; + +/** + * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + * + * @internal + */ +@Component({ + tag: 'atomic-commerce-facet', + styleUrl: 'atomic-commerce-facet.pcss', + shadow: true, +}) +export class AtomicCommerceFacet implements InitializableComponent { + @InitializeBindings() public bindings!: Bindings; + public summary!: SearchSummary | ListingSummary; + @Element() private host!: HTMLElement; + + @Prop() public facet!: RegularFacet; + + @BindStateToController('facet') + @State() + public facetState!: RegularFacetState; + + @State() public error!: Error; + + private isCollapsed = false; + private showLessFocus?: FocusTargetController; + private showMoreFocus?: FocusTargetController; + private headerFocus?: FocusTargetController; + + @AriaLiveRegion('facet-search') + protected facetSearchAriaMessage!: string; + + public initialize() { + this.initSummary(); + this.initAriaLive(); + this.initPopover(); + this.registerFacet(); + } + + public componentShouldUpdate( + next: unknown, + prev: unknown, + propName: keyof AtomicCommerceFacet + ) { + if ( + this.isFacetState(prev, propName) && + this.isFacetState(next, propName) + ) { + return shouldUpdateFacetSearchComponent( + next.facetSearch, + prev.facetSearch + ); + } + return true; + } + + public render() { + const {hasError, firstSearchExecuted} = this.summary.state; + return ( + 0} + > + {firstSearchExecuted ? ( + + { + this.focusTargets.header.focusAfterSearch(); + this.facet.deselectAll(); + }} + numberOfActiveValues={this.activeValues.length} + isCollapsed={this.isCollapsed} + headingLevel={0} + onToggleCollapse={() => (this.isCollapsed = !this.isCollapsed)} + headerRef={(el) => this.focusTargets.header.setTarget(el)} + > + {this.renderBody()} + + ) : ( + + )} + + ); + } + + private renderBody() { + if (this.isCollapsed) { + return; + } + return ( + + + { + if (value === '') { + this.facet.facetSearch.clear(); + return; + } + this.facet.facetSearch.updateText(value); + this.facet.facetSearch.search(); + }} + onClear={() => this.facet.facetSearch.clear()} + query={this.facetState.facetSearch.query} + /> + + {shouldDisplaySearchResults(this.facetState.facetSearch) + ? [this.renderSearchResults(), this.renderMatches()] + : [this.renderValues(), this.renderShowMoreLess()]} + + ); + } + + private renderValuesContainer(children: VNode[], query?: string) { + return ( + +
    {children}
+
+ ); + } + + private renderSearchResults() { + return this.renderValuesContainer( + this.facet.state.facetSearch.values.map((value) => ( + this.facet.facetSearch.exclude(value)} + onSelect={() => this.facet.facetSearch.select(value)} + facetValue={value.rawValue} + /> + )) + ); + } + + private renderValues() { + return this.renderValuesContainer( + this.facet.state.values.map((value, i) => { + const shouldFocusOnShowLessAfterInteraction = i === 0; + const shouldFocusOnShowMoreAfterInteraction = i === 0; + + return ( + this.facet.toggleExclude(value)} + onSelect={() => this.facet.toggleSelect(value)} + facetValue={value.value} + facetState={value.state} + setRef={(btn) => { + if (shouldFocusOnShowLessAfterInteraction) { + this.showLessFocus?.setTarget(btn); + } + if (shouldFocusOnShowMoreAfterInteraction) { + this.showMoreFocus?.setTarget(btn); + } + }} + /> + ); + }) + ); + } + + private renderShowMoreLess() { + return ( + { + this.focusTargets.showMore.focusAfterSearch(); + this.facet.showMoreValues(); + }} + onShowLess={() => { + this.focusTargets.showLess.focusAfterSearch(); + this.facet.showLessValues(); + }} + canShowMoreValues={this.facet.state.canShowMoreValues} + canShowLessValues={this.facet.state.canShowLessValues} + > + ); + } + + private renderMatches() { + return ( + + ); + } + + private get activeValues() { + return this.facet.state.values.filter(({state}) => state !== 'idle'); + } + + private get displayName() { + return this.facet.state.displayName || 'no-label'; + } + + private get facetValueProps(): Pick< + FacetValueProps, + | 'facetSearchQuery' + | 'enableExclusion' + | 'field' + | 'i18n' + | 'displayValuesAs' + > { + return { + facetSearchQuery: this.facetState.facetSearch.query, + displayValuesAs: 'checkbox', + enableExclusion: false, + field: this.facetState.field, + i18n: this.bindings.i18n, + }; + } + + private get isHidden() { + return !this.facetState.values.length; + } + + private registerFacet() { + this.bindings.store.registerFacet('facets', this.facetInfo); + } + + private initPopover() { + initializePopover(this.host, { + ...this.facetInfo, + hasValues: () => !!this.facet.state.values.length, + numberOfActiveValues: () => this.activeValues.length, + }); + } + + private initSummary() { + if (this.bindings.interfaceElement.type === 'product-listing') { + this.summary = buildListingSummary(this.bindings.engine); + } else { + this.summary = buildSearchSummary(this.bindings.engine); + } + } + + private initAriaLive() { + announceFacetSearchResultsWithAriaLive( + this.facet, + this.displayName, + (msg) => (this.facetSearchAriaMessage = msg), + this.bindings.i18n + ); + } + + private get facetInfo(): FacetInfo { + return { + label: () => this.bindings.i18n.t(this.displayName), + facetId: this.facet.state.facetId, + element: this.host, + isHidden: () => this.isHidden, + }; + } + + private get focusTargets(): { + showLess: FocusTargetController; + showMore: FocusTargetController; + header: FocusTargetController; + } { + if (!this.showLessFocus) { + this.showLessFocus = new FocusTargetController(this); + } + if (!this.showMoreFocus) { + this.showMoreFocus = new FocusTargetController(this); + } + if (!this.headerFocus) { + this.headerFocus = new FocusTargetController(this); + } + + return { + showLess: this.showLessFocus, + showMore: this.showMoreFocus, + header: this.headerFocus, + }; + } + + private isFacetState( + state: unknown, + propName: string + ): state is RegularFacetState { + return ( + propName === 'facetState' && + typeof (state as RegularFacetState)?.facetId === 'string' + ); + } +} diff --git a/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts b/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts index c5f957a49e3..d85aadeca67 100644 --- a/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts +++ b/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts @@ -130,7 +130,7 @@ export type CoreCommerceFacet< */ export type CoreCommerceFacetState< ValueResponse extends AnyFacetValueResponse, -> = Omit & { +> = Omit & { /** * The type of facet. */