diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts index 5bb94f659f9..9f806236047 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts @@ -11,6 +11,7 @@ AtomicAutomaticFacetGenerator, AtomicBreadbox, AtomicCategoryFacet, AtomicColorFacet, +AtomicCommerceFacet, AtomicCommerceFacets, AtomicCommerceLoadMoreProducts, AtomicCommerceQuerySummary, @@ -112,6 +113,7 @@ AtomicAutomaticFacetGenerator, AtomicBreadbox, AtomicCategoryFacet, AtomicColorFacet, +AtomicCommerceFacet, AtomicCommerceFacets, AtomicCommerceLoadMoreProducts, AtomicCommerceQuerySummary, diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts index 80eefff5d7c..fab9b6297e0 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts @@ -138,6 +138,27 @@ export class AtomicColorFacet { export declare interface AtomicColorFacet extends Components.AtomicColorFacet {} +@ProxyCmp({ +}) +@Component({ + selector: 'atomic-commerce-facet', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: [], +}) +export class AtomicCommerceFacet { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface AtomicCommerceFacet extends Components.AtomicCommerceFacet {} + + @ProxyCmp({ inputs: ['collapseFacetsAfter'] }) diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index b3f1a7b6e5e..08f0495f3e8 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -291,10 +291,12 @@ export namespace Components { } /** * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + * @alpha */ interface AtomicCommerceFacet { /** * The facet controller instance. + * @ */ "facet": RegularFacet; /** @@ -3495,6 +3497,7 @@ declare global { }; /** * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + * @alpha */ interface HTMLAtomicCommerceFacetElement extends Components.AtomicCommerceFacet, HTMLStencilElement { } @@ -5726,10 +5729,12 @@ declare namespace LocalJSX { } /** * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + * @alpha */ interface AtomicCommerceFacet { /** * The facet controller instance. + * @ */ "facet": RegularFacet; /** @@ -8830,6 +8835,7 @@ declare module "@stencil/core" { "atomic-commerce-did-you-mean": LocalJSX.AtomicCommerceDidYouMean & JSXBase.HTMLAttributes; /** * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. + * @alpha */ "atomic-commerce-facet": LocalJSX.AtomicCommerceFacet & JSXBase.HTMLAttributes; /** diff --git a/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.new.stories.tsx b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.new.stories.tsx new file mode 100644 index 00000000000..0958f8a521a --- /dev/null +++ b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/atomic-commerce-facet.new.stories.tsx @@ -0,0 +1,39 @@ +import { + wrapInCommerceInterface, + playExecuteFirstSearch, + playKeepOnlyFirstFacetOfType, +} from '@coveo/atomic/storybookUtils/commerce-interface-wrapper'; +import {parameters} from '@coveo/atomic/storybookUtils/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic/storybookUtils/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {html} from 'lit-html'; + +const {play, decorator} = wrapInCommerceInterface({skipFirstSearch: true}); + +const meta: Meta = { + component: 'atomic-commerce-facet', + title: 'Atomic-Commerce/Facet', + id: 'atomic-commerce-facet', + render: renderComponent, + decorators: [decorator], + parameters, + play, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-commerce-facet', + decorators: [ + (_) => { + return html`
+ +
`; + }, + ], + play: async (context) => { + await play(context); + await playExecuteFirstSearch(context); + playKeepOnlyFirstFacetOfType('atomic-commerce-facet', context); + }, +}; 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 index b13cfe5fd05..57813635e7c 100644 --- 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 @@ -48,7 +48,42 @@ import {CommerceBindings as Bindings} from '../../atomic-commerce-interface/atom /** * The `atomic-commerce-facet` component renders a commerce facet that the end user can interact with to filter products. * - * @internal + * @part facet - The wrapper for the entire facet. + * @part placeholder - The placeholder shown before the first search is executed. + * + * @part label-button - The button that displays the label and allows to expand/collapse the facet. + * @part label-button-icon - The label button icon. + * @part clear-button - The button that resets the actively selected facet values. + * @part clear-button-icon - The clear button icon. + * + * @part search-wrapper - The search box wrapper. + * @part search-input - The search box input. + * @part search-icon - The search box submit button. + * @part search-clear-button - The button to clear the search box of input. + * @part more-matches - The label indicating there are more matches for the current facet search query. + * @part no-matches - The label indicating there are no matches for the current facet search query. + * @part matches-query - The highlighted query inside the matches labels. + * @part search-highlight - The highlighted query inside the facet values. + * + * @part values - The facet values container. + * @part value-label - The facet value label, common for all displays. + * @part value-count - The facet value count, common for all displays. + * + * @part value-checkbox - The facet value checkbox, available when display is 'checkbox'. + * @part value-checkbox-checked - The checked facet value checkbox, available when display is 'checkbox'. + * @part value-checkbox-label - The facet value checkbox clickable label, available when display is 'checkbox'. + * @part value-checkbox-icon - The facet value checkbox icon, available when display is 'checkbox'. + * @part value-link - The facet value when display is 'link'. + * @part value-link-selected - The selected facet value when display is 'link'. + * @part value-box - The facet value when display is 'box'. + * @part value-box-selected - The selected facet value when display is 'box'. + * @part value-exclude-button - The button to exclude a facet value, available when display is 'checkbox'. + * + * @part show-more - The show more results button. + * @part show-less - The show less results button. + * @part show-more-less-icon - The icons of the show more & show less buttons. + * + * @alpha */ @Component({ tag: 'atomic-commerce-facet', @@ -61,18 +96,26 @@ export class AtomicCommerceFacet implements InitializableComponent { /** * The Summary controller instance. + * + * @internal */ @Prop() summary!: Summary; /** * The facet controller instance. + * + * @@internal */ @Prop() public facet!: RegularFacet; /** * Specifies whether the facet is collapsed. + * + * @internal */ @Prop({reflect: true, mutable: true}) public isCollapsed = false; /** * The field identifier for this facet. + * + * @internal */ @Prop({reflect: true}) field?: string; @@ -90,6 +133,9 @@ export class AtomicCommerceFacet implements InitializableComponent { protected facetSearchAriaMessage!: string; public initialize() { + if (!this.facet) { + return; + } this.initAriaLive(); this.initPopover(); this.registerFacet(); @@ -113,6 +159,9 @@ export class AtomicCommerceFacet implements InitializableComponent { } public render() { + if (!this.facet) { + return; + } const {hasError, firstRequestExecuted} = this.summary.state; return ( { + test.beforeEach(async ({facet}) => { + await facet.load(); + }); + + test('should be A11y compliant', async ({facet, makeAxeBuilder}) => { + await facet.hydrated.waitFor(); + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations).toEqual([]); + }); + + test('should allow to filter by selecting and deselecting a value', async ({ + facet, + }) => { + const facetValueLabel = facet.getFacetValue('Nike'); + const facetValueBtn = facet.getFacetValueButton('Nike'); + + await expect(facetValueBtn).not.toBeChecked(); + await facetValueLabel.click(); + + await expect(facetValueBtn).toBeChecked(); + + await facetValueLabel.click(); + await expect(facetValueBtn).not.toBeChecked(); + }); + + test('should allow to filter by selecting multiple values', async ({ + facet, + page, + }) => { + const firstValueBtn = facet.getFacetValueButton('Nike'); + const secondValueBtn = facet.getFacetValueButton('Adidas'); + + await expect(firstValueBtn).not.toBeChecked(); + await expect(secondValueBtn).not.toBeChecked(); + await firstValueBtn.click(); + await secondValueBtn.click(); + + await expect(firstValueBtn).toBeChecked(); + await expect(secondValueBtn).toBeChecked(); + await expect(page.getByText('Clear 2 filters')).toBeVisible(); + }); + + test('should allow to deselect a filter with the clear button', async ({ + facet, + }) => { + const facetValueLabel = facet.getFacetValue('Nike'); + + await expect(facet.clearFilter).toHaveCount(0); + + await facetValueLabel.click(); + + await expect(facet.clearFilter).toBeVisible(); + await facet.clearFilter.click(); + + await expect(facet.clearFilter).toHaveCount(0); + }); + + test('should allow to show more values and show less values', async ({ + facet, + page, + }) => { + await expect(facet.showMore).toBeVisible(); + await expect(facet.showLess).not.toBeVisible(); + + await expect(page.getByRole('listitem')).toHaveCount(8); + + await facet.showMore.click(); + + await expect(facet.showLess).toBeVisible(); + await expect(page.getByRole('listitem')).toHaveCount(16); + await facet.showLess.click(); + await expect(page.getByRole('listitem')).toHaveCount(8); + await expect(facet.showLess).not.toBeVisible(); + }); + + test('allow to search for a value', async ({facet, page}) => { + await facet.searchInput.fill('n'); + + expect(await page.getByRole('listitem').count()).toBeGreaterThanOrEqual(8); + await expect(page.getByText('More matches for n')).toBeVisible(); + await facet.searchInput.fill('nike'); + + await facet.getFacetValue('Nike').click(); + + await expect(facet.getFacetValueButton('Nike')).toBeChecked(); + }); + + test('allow to clear the search input', async ({facet}) => { + await facet.searchInput.fill('nike'); + await expect(facet.clearSearchInput).toBeVisible(); + + await facet.clearSearchInput.click(); + await expect(facet.clearSearchInput).not.toBeVisible(); + await expect(facet.searchInput).toBeEmpty(); + }); + + test('behave correct when searching for a value that does not exist', async ({ + facet, + page, + }) => { + await facet.searchInput.fill('non-existing-value'); + + await expect( + page.getByText('No matches found for non-existing-value') + ).toBeVisible(); + }); +}); diff --git a/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/e2e/fixture.ts b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/e2e/fixture.ts new file mode 100644 index 00000000000..8e5adb37bfb --- /dev/null +++ b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/e2e/fixture.ts @@ -0,0 +1,19 @@ +import {test as base} from '@playwright/test'; +import { + AxeFixture, + makeAxeBuilder, +} from '../../../../../../playwright-utils/base-fixture'; +import {FacetPageObject} from './page-object'; + +type MyFixtures = { + facet: FacetPageObject; +}; + +export const test = base.extend({ + makeAxeBuilder, + facet: async ({page}, use) => { + await use(new FacetPageObject(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/e2e/page-object.ts b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/e2e/page-object.ts new file mode 100644 index 00000000000..7ad4f0c0cc1 --- /dev/null +++ b/packages/atomic/src/components/commerce/facets/atomic-commerce-facet/e2e/page-object.ts @@ -0,0 +1,40 @@ +import type {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class FacetPageObject extends BasePageObject<'atomic-commerce-facet'> { + constructor(page: Page) { + super(page, 'atomic-commerce-facet'); + } + + get title() { + return this.page.getByText('Brand'); + } + + get searchInput() { + return this.page.getByPlaceholder('Search'); + } + + get clearSearchInput() { + return this.page.getByRole('button', {name: 'Clear'}); + } + + getFacetValue(value: string) { + return this.page.getByRole('listitem').filter({hasText: value}); + } + + getFacetValueButton(value: string) { + return this.page.getByLabel(`Inclusion filter on ${value}`); + } + + get clearFilter() { + return this.page.getByRole('button').filter({hasText: /Clear.*filter/}); + } + + get showMore() { + return this.page.getByLabel('Show more values'); + } + + get showLess() { + return this.page.getByLabel('Show less values'); + } +} diff --git a/packages/atomic/storybookUtils/commerce-interface-wrapper.tsx b/packages/atomic/storybookUtils/commerce-interface-wrapper.tsx index a2af87835bc..47b6a5d6c06 100644 --- a/packages/atomic/storybookUtils/commerce-interface-wrapper.tsx +++ b/packages/atomic/storybookUtils/commerce-interface-wrapper.tsx @@ -107,3 +107,35 @@ export const playExecuteFirstSearch: ( await searchInterface!.executeFirstSearch(); }); }; + +export const playKeepOnlyFirstFacetOfType = ( + facetType: string, + context: StoryContext +) => { + const observer = new MutationObserver(() => { + const childNodes = Array.from( + context.canvasElement.querySelector('atomic-commerce-facets') + ?.childNodes || [] + ); + + const allFacetsMatching = childNodes.filter((node) => { + return node.nodeName.toLowerCase() === facetType; + }); + + const allAtomicElementNotOfType = childNodes.filter((node) => { + return ( + node.nodeName.toLowerCase().indexOf('atomic') !== -1 && + node.nodeName.toLowerCase() !== facetType + ); + }); + + allAtomicElementNotOfType.forEach((node) => node.remove()); + allFacetsMatching.slice(1).forEach((node) => node.remove()); + }); + + observer.observe(context.canvasElement, { + childList: true, + subtree: true, + attributes: true, + }); +};