From 5c235a8a9a3eaab77a42b819a4e8b92b59aa3b36 Mon Sep 17 00:00:00 2001 From: Felix Perron-Brault Date: Mon, 28 Oct 2024 15:56:35 -0400 Subject: [PATCH] feat(atomic): support highlights in atomic-product-description (#4541) This PR adds support for highlights in atomic-product-description. Highlights are only supported when using the excerpt field. https://coveord.atlassian.net/browse/KIT-3307 --------- Co-authored-by: GitHub Actions Bot <> Co-authored-by: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Co-authored-by: Frederic Beaudoin --- .../atomic-angular.module.ts | 2 + .../src/lib/stencil-generated/components.ts | 26 ++- .../stencil-generated/commerce/index.ts | 1 + packages/atomic/src/components.d.ts | 55 +++++- ...atomic-product-description.new.stories.tsx | 68 +++++++ .../atomic-product-description.pcss | 1 - .../atomic-product-description.tsx | 87 ++++----- .../e2e/atomic-product-description.e2e.ts | 172 ++++++++++++++++++ .../atomic-product-description/e2e/fixture.ts | 19 ++ .../e2e/page-object.ts | 24 +++ .../atomic-product-excerpt.new.stories.tsx | 57 ++++++ .../atomic-product-excerpt.tsx | 118 ++++++++++++ .../e2e/atomic-product-excerpt.e2e.ts | 142 +++++++++++++++ .../atomic-product-excerpt/e2e/fixture.ts | 19 ++ .../atomic-product-excerpt/e2e/page-object.ts | 24 +++ .../atomic-product-text.new.stories.tsx | 63 +++++++ .../e2e/atomic-product-text.e2e.ts | 95 ++++++++++ .../atomic-product-text/e2e/fixture.ts | 24 +++ .../atomic-product-text/e2e/page-object.ts | 16 ++ .../expandable-text/expandable-text.tsx | 93 ++++++++++ .../examples/commerce-website/search.html | 2 +- 21 files changed, 1047 insertions(+), 61 deletions(-) create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx delete mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts create mode 100644 packages/atomic/src/components/common/expandable-text/expandable-text.tsx 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 f523722e45a..200e7f34a41 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 @@ -62,6 +62,7 @@ AtomicPopover, AtomicProduct, AtomicProductChildren, AtomicProductDescription, +AtomicProductExcerpt, AtomicProductFieldCondition, AtomicProductImage, AtomicProductLink, @@ -205,6 +206,7 @@ AtomicPopover, AtomicProduct, AtomicProductChildren, AtomicProductDescription, +AtomicProductExcerpt, AtomicProductFieldCondition, AtomicProductImage, AtomicProductLink, 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 5fa2cee9ec5..c83226b2dee 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 @@ -1278,14 +1278,14 @@ export declare interface AtomicProductChildren extends Components.AtomicProductC @ProxyCmp({ - inputs: ['field', 'truncateAfter'] + inputs: ['field', 'isCollapsible', 'truncateAfter'] }) @Component({ selector: 'atomic-product-description', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['field', 'truncateAfter'], + inputs: ['field', 'isCollapsible', 'truncateAfter'], }) export class AtomicProductDescription { protected el: HTMLElement; @@ -1299,6 +1299,28 @@ export class AtomicProductDescription { export declare interface AtomicProductDescription extends Components.AtomicProductDescription {} +@ProxyCmp({ + inputs: ['isCollapsible', 'truncateAfter'] +}) +@Component({ + selector: 'atomic-product-excerpt', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['isCollapsible', 'truncateAfter'], +}) +export class AtomicProductExcerpt { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface AtomicProductExcerpt extends Components.AtomicProductExcerpt {} + + @ProxyCmp({ inputs: ['ifDefined', 'ifNotDefined', 'mustMatch', 'mustNotMatch'] }) diff --git a/packages/atomic-react/src/components/stencil-generated/commerce/index.ts b/packages/atomic-react/src/components/stencil-generated/commerce/index.ts index 1d1213dc90c..ed83c86f8f1 100644 --- a/packages/atomic-react/src/components/stencil-generated/commerce/index.ts +++ b/packages/atomic-react/src/components/stencil-generated/commerce/index.ts @@ -41,6 +41,7 @@ export const AtomicNumericRange = /*@__PURE__*/createReactComponent('atomic-product'); export const AtomicProductChildren = /*@__PURE__*/createReactComponent('atomic-product-children'); export const AtomicProductDescription = /*@__PURE__*/createReactComponent('atomic-product-description'); +export const AtomicProductExcerpt = /*@__PURE__*/createReactComponent('atomic-product-excerpt'); export const AtomicProductFieldCondition = /*@__PURE__*/createReactComponent('atomic-product-field-condition'); export const AtomicProductImage = /*@__PURE__*/createReactComponent('atomic-product-image'); export const AtomicProductLink = /*@__PURE__*/createReactComponent('atomic-product-link'); diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index a2ac7066608..beb42d4e7ae 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -28,6 +28,7 @@ import { InsightResultActionClickedEvent } from "./components/insight/atomic-ins import { Section } from "./components/common/atomic-layout-section/sections"; import { AtomicCommonStore, AtomicCommonStoreData } from "./components/common/interface/store"; import { SelectChildProductEventArgs } from "./components/commerce/product-template-components/atomic-product-children/atomic-product-children"; +import { TruncateAfter } from "./components/common/expandable-text/expandable-text"; import { RecommendationEngine } from "@coveo/headless/recommendation"; import { InteractiveResult as RecsInteractiveResult, LogLevel as RecsLogLevel, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "./components/recommendations"; import { RecsInitializationOptions } from "./components/recommendations/atomic-recs-interface/atomic-recs-interface"; @@ -58,6 +59,7 @@ export { InsightResultActionClickedEvent } from "./components/insight/atomic-ins export { Section } from "./components/common/atomic-layout-section/sections"; export { AtomicCommonStore, AtomicCommonStoreData } from "./components/common/interface/store"; export { SelectChildProductEventArgs } from "./components/commerce/product-template-components/atomic-product-children/atomic-product-children"; +export { TruncateAfter } from "./components/common/expandable-text/expandable-text"; export { RecommendationEngine } from "@coveo/headless/recommendation"; export { InteractiveResult as RecsInteractiveResult, LogLevel as RecsLogLevel, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "./components/recommendations"; export { RecsInitializationOptions } from "./components/recommendations/atomic-recs-interface/atomic-recs-interface"; @@ -2072,10 +2074,27 @@ export namespace Components { * The name of the description field to use. */ "field": 'ec_description' | 'ec_shortdesc'; + /** + * Whether the description should be collapsible after being expanded. + */ + "isCollapsible": boolean; /** * The number of lines after which the product description should be truncated. A value of "none" will disable truncation. */ - "truncateAfter": 'none' | '1' | '2' | '3' | '4'; + "truncateAfter": TruncateAfter; + } + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + interface AtomicProductExcerpt { + /** + * Whether the excerpt should be collapsible after being expanded. + */ + "isCollapsible": boolean; + /** + * The number of lines after which the product excerpt should be truncated. A value of "none" will disable truncation. + */ + "truncateAfter": TruncateAfter; } /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. @@ -4909,6 +4928,15 @@ declare global { prototype: HTMLAtomicProductDescriptionElement; new (): HTMLAtomicProductDescriptionElement; }; + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + interface HTMLAtomicProductExcerptElement extends Components.AtomicProductExcerpt, HTMLStencilElement { + } + var HTMLAtomicProductExcerptElement: { + prototype: HTMLAtomicProductExcerptElement; + new (): HTMLAtomicProductExcerptElement; + }; /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. * The condition properties can be based on any top-level product property of the `product` object, not restricted to fields (e.g., `ec_name`). @@ -6061,6 +6089,7 @@ declare global { "atomic-product": HTMLAtomicProductElement; "atomic-product-children": HTMLAtomicProductChildrenElement; "atomic-product-description": HTMLAtomicProductDescriptionElement; + "atomic-product-excerpt": HTMLAtomicProductExcerptElement; "atomic-product-field-condition": HTMLAtomicProductFieldConditionElement; "atomic-product-image": HTMLAtomicProductImageElement; "atomic-product-link": HTMLAtomicProductLinkElement; @@ -8080,10 +8109,27 @@ declare namespace LocalJSX { * The name of the description field to use. */ "field"?: 'ec_description' | 'ec_shortdesc'; + /** + * Whether the description should be collapsible after being expanded. + */ + "isCollapsible"?: boolean; /** * The number of lines after which the product description should be truncated. A value of "none" will disable truncation. */ - "truncateAfter"?: 'none' | '1' | '2' | '3' | '4'; + "truncateAfter"?: TruncateAfter; + } + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + interface AtomicProductExcerpt { + /** + * Whether the excerpt should be collapsible after being expanded. + */ + "isCollapsible"?: boolean; + /** + * The number of lines after which the product excerpt should be truncated. A value of "none" will disable truncation. + */ + "truncateAfter"?: TruncateAfter; } /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. @@ -9801,6 +9847,7 @@ declare namespace LocalJSX { "atomic-product": AtomicProduct; "atomic-product-children": AtomicProductChildren; "atomic-product-description": AtomicProductDescription; + "atomic-product-excerpt": AtomicProductExcerpt; "atomic-product-field-condition": AtomicProductFieldCondition; "atomic-product-image": AtomicProductImage; "atomic-product-link": AtomicProductLink; @@ -10251,6 +10298,10 @@ declare module "@stencil/core" { * @alpha The `atomic-product-description` component renders the description of a product. */ "atomic-product-description": LocalJSX.AtomicProductDescription & JSXBase.HTMLAttributes; + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + "atomic-product-excerpt": LocalJSX.AtomicProductExcerpt & JSXBase.HTMLAttributes; /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. * The condition properties can be based on any top-level product property of the `product` object, not restricted to fields (e.g., `ec_name`). diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx new file mode 100644 index 00000000000..dd7b6388d57 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx @@ -0,0 +1,68 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {updateQuery} from '../../../../../../headless/src/features/commerce/query/query-actions'; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: true, +}); + +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-description', + title: 'Atomic-Commerce/Product Template Components/ProductDescription', + id: 'atomic-product-description', + render: renderComponent, + parameters, + argTypes: { + 'attributes-truncate-after': { + name: 'truncate-after', + type: 'string', + }, + 'attributes-field': { + name: 'field', + type: 'string', + }, + 'attributes-is-collapsible': { + name: 'is-collapsible', + type: 'boolean', + }, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-description', + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + ], + play: async (context) => { + await initializeCommerceInterface(context); + + const searchInterface = context.canvasElement.querySelector( + 'atomic-commerce-interface' + ); + searchInterface?.engine?.dispatch(updateQuery({query: 'kayak'})); + + await searchInterface!.executeFirstRequest(); + }, + args: { + 'attributes-field': 'ec_description', + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss deleted file mode 100644 index 7a0133e5e82..00000000000 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss +++ /dev/null @@ -1 +0,0 @@ -@import '../../../../global/global.pcss'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx index 770a7fcb127..2fbc9e15f97 100644 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx @@ -1,13 +1,15 @@ import {Schema, StringValue} from '@coveo/bueno'; import {Product} from '@coveo/headless/commerce'; import {Component, State, h, Element, Prop} from '@stencil/core'; -import PlusIcon from '../../../../images/plus.svg'; -import {getFieldValueCaption} from '../../../../utils/field-utils'; import { InitializableComponent, InitializeBindings, } from '../../../../utils/initialization-utils'; -import {Button} from '../../../common/button'; +import { + ExpandableText, + TruncateAfter, +} from '../../../common/expandable-text/expandable-text'; +import {Hidden} from '../../../common/hidden'; import {CommerceBindings} from '../../atomic-commerce-interface/atomic-commerce-interface'; import {ProductContext} from '../product-template-decorators'; @@ -17,7 +19,6 @@ import {ProductContext} from '../product-template-decorators'; */ @Component({ tag: 'atomic-product-description', - styleUrl: 'atomic-product-description.pcss', shadow: false, }) export class AtomicProductDescription @@ -39,13 +40,18 @@ export class AtomicProductDescription /** * The number of lines after which the product description should be truncated. A value of "none" will disable truncation. */ - @Prop() public truncateAfter: 'none' | '1' | '2' | '3' | '4' = '2'; + @Prop() public truncateAfter: TruncateAfter = '2'; /** * The name of the description field to use. */ @Prop() public field: 'ec_description' | 'ec_shortdesc' = 'ec_shortdesc'; + /** + * Whether the description should be collapsible after being expanded. + */ + @Prop() public isCollapsible = true; + constructor() { this.resizeObserver = new ResizeObserver(() => { if ( @@ -65,7 +71,9 @@ export class AtomicProductDescription truncateAfter: new StringValue({ constrainTo: ['none', '1', '2', '3', '4'], }), - field: new StringValue({constrainTo: ['ec_shortdesc', 'ec_description']}), + field: new StringValue({ + constrainTo: ['ec_shortdesc', 'ec_description'], + }), }).validate({ truncateAfter: this.truncateAfter, field: this.field, @@ -74,7 +82,7 @@ export class AtomicProductDescription componentDidLoad() { this.descriptionText = this.hostElement.querySelector( - '.product-description-text' + '.expandable-text' ) as HTMLDivElement; if (this.descriptionText) { this.resizeObserver.observe(this.descriptionText); @@ -89,62 +97,31 @@ export class AtomicProductDescription this.isExpanded = !this.isExpanded; } - private getLineClampClass() { - const lineClampMap: Record = { - none: 'line-clamp-none', - 1: 'line-clamp-1', - 2: 'line-clamp-2', - 3: 'line-clamp-3', - 4: 'line-clamp-4', - }; - return lineClampMap[this.truncateAfter] || 'line-clamp-2'; - } - disconnectedCallback() { this.resizeObserver.disconnect(); } - private renderProductDescription() { - const productDescription = this.product[this.field] ?? ''; - - if (productDescription !== null) { - return ( - - ); + public render() { + const productDescription = this.product[this.field] ?? null; + + if (!productDescription) { + return ; } - } - private renderShowMoreButton() { return ( - - ); - } - - public render() { - return ( -
- {this.renderProductDescription()} - {this.renderShowMoreButton()} -
+ + {productDescription} + + ); } } diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts new file mode 100644 index 00000000000..2e7d62f538e --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts @@ -0,0 +1,172 @@ +import {test, expect} from './fixture'; + +test.describe('atomic-product-description', async () => { + test.beforeEach(async ({page, productDescription}) => { + await page.setViewportSize({width: 375, height: 667}); + await productDescription.load(); + await productDescription.hydrated.first().waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test.describe('when providing an invalid field', async () => { + test('should return error', async ({page, productDescription}) => { + await productDescription.load({ + args: { + // @ts-expect-error needed to test error on invalid field + field: 'ec_name', + }, + }); + + const errorMessage = await page.waitForEvent('console', (msg) => { + return msg.type() === 'error'; + }); + + expect(errorMessage.text()).toContain( + 'field: value should be one of: ec_shortdesc, ec_description.' + ); + }); + }); + test.describe('when providing an invalid truncate-after value', async () => { + const invalidValues = ['foo', '0', '-1', '5']; + + invalidValues.forEach((value) => { + test(`should return error for value: ${value}`, async ({ + page, + productDescription, + }) => { + await productDescription.load({ + args: { + // @ts-expect-error needed to test error on invalid value + truncateAfter: value, + }, + }); + + const errorMessage = await page.waitForEvent('console', (msg) => { + return msg.type() === 'error'; + }); + + expect(errorMessage.text()).toContain( + 'truncateAfter: value should be one of: none, 1, 2, 3, 4' + ); + }); + }); + }); + + const fields: Array<'ec_description' | 'ec_shortdesc'> = [ + 'ec_description', + 'ec_shortdesc', + ]; + + fields.forEach((field) => { + test(`should render description for ${field} field`, async ({ + productDescription, + }) => { + await productDescription.load({args: {field}}); + await productDescription.hydrated.first().waitFor(); + + expect(productDescription.textContent.first()).toBeVisible(); + }); + }); + + test.describe('when description is truncated', async () => { + const truncateValues: Array<{ + value: '1' | '2' | '3' | '4'; + expectedClass: RegExp; + }> = [ + {value: '1', expectedClass: /line-clamp-1/}, + {value: '2', expectedClass: /line-clamp-2/}, + {value: '3', expectedClass: /line-clamp-3/}, + {value: '4', expectedClass: /line-clamp-4/}, + ]; + + truncateValues.forEach(({value, expectedClass}) => { + test.describe(`when truncateAfter is set to ${value}`, async () => { + test(`should truncate description after ${value} lines`, async ({ + productDescription, + }) => { + await productDescription.load({ + args: {truncateAfter: value}, + }); + await productDescription.hydrated.first().waitFor(); + + const descriptionText = productDescription.textContent.first(); + expect(descriptionText).toHaveClass(expectedClass); + }); + + test('should show "Show More" button', async ({productDescription}) => { + const showMoreButton = productDescription.showMoreButton.first(); + expect(showMoreButton).toBeVisible(); + }); + + test.describe('when clicking the "Show More" button', async () => { + test.describe('when isCollapsible is true', async () => { + test.beforeEach(async ({productDescription}) => { + await productDescription.load({ + args: {truncateAfter: value, isCollapsible: true}, + }); + await productDescription.hydrated.first().waitFor(); + await productDescription.showMoreButton.first().click(); + }); + + test('should expand description', async ({productDescription}) => { + const descriptionText = productDescription.textContent.first(); + expect(descriptionText).not.toHaveClass(expectedClass); + }); + + test('should show "Show Less" button', async ({ + productDescription, + }) => { + const showLessButton = productDescription.showLessButton.first(); + expect(showLessButton).toBeVisible(); + }); + + test('should collapse description when clicking the "Show Less" button', async ({ + productDescription, + }) => { + const descriptionText = productDescription.textContent.first(); + await productDescription.showLessButton.first().click(); + + expect(descriptionText).toHaveClass(expectedClass); + }); + }); + + test.describe('when isCollapsible is false', async () => { + test.beforeEach(async ({productDescription}) => { + await productDescription.load({ + args: {truncateAfter: value, isCollapsible: false}, + }); + await productDescription.hydrated.first().waitFor(); + await productDescription.showMoreButton.first().click(); + }); + + test('should expand description', async ({productDescription}) => { + const descriptionText = productDescription.textContent.first(); + expect(descriptionText).not.toHaveClass(expectedClass); + }); + + test('should not show "Show Less" button', async ({ + productDescription, + }) => { + expect(productDescription.showLessButton).not.toBeVisible(); + }); + }); + }); + }); + }); + }); + + test.describe('when description is not truncated', async () => { + test('should hide "Show More" button ', async ({productDescription}) => { + await productDescription.load({ + args: {truncateAfter: 'none'}, + }); + await productDescription.hydrated.first().waitFor(); + + expect(productDescription.showMoreButton).not.toBeVisible(); + }); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts new file mode 100644 index 00000000000..0641f0049af --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts @@ -0,0 +1,19 @@ +import {test as base} from '@playwright/test'; +import { + makeAxeBuilder, + AxeFixture, +} from '../../../../../../playwright-utils/base-fixture'; +import {ProductDescriptionPageObject as ProductDescription} from './page-object'; + +type MyFixtures = { + productDescription: ProductDescription; +}; + +export const test = base.extend({ + makeAxeBuilder, + productDescription: async ({page}, use) => { + await use(new ProductDescription(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts new file mode 100644 index 00000000000..ada969ac4e8 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts @@ -0,0 +1,24 @@ +import {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class ProductDescriptionPageObject extends BasePageObject<'atomic-product-description'> { + constructor(page: Page) { + super(page, 'atomic-product-description'); + } + + get textContent() { + return this.page.locator('.expandable-text'); + } + + get highlightedText() { + return this.page.locator('atomic-product-text b'); + } + + get showMoreButton() { + return this.page.getByRole('button', {name: 'Show more'}); + } + + get showLessButton() { + return this.page.getByRole('button', {name: 'Show less'}); + } +} diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx new file mode 100644 index 00000000000..0b46b949bbf --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx @@ -0,0 +1,57 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {updateQuery} from '../../../../../../headless/src/features/commerce/query/query-actions'; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: true, +}); + +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-excerpt', + title: 'Atomic-Commerce/Product Template Components/ProductExcerpt', + id: 'atomic-product-excerpt', + render: renderComponent, + parameters, + argTypes: { + 'attributes-truncate-after': { + name: 'truncate-after', + type: 'string', + }, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-excerpt', + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + ], + play: async (context) => { + await initializeCommerceInterface(context); + + const searchInterface = context.canvasElement.querySelector( + 'atomic-commerce-interface' + ); + searchInterface?.engine?.dispatch(updateQuery({query: 'kayak'})); + + await searchInterface!.executeFirstRequest(); + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx new file mode 100644 index 00000000000..1b53322af2a --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx @@ -0,0 +1,118 @@ +import {Schema, StringValue} from '@coveo/bueno'; +import {Product} from '@coveo/headless/commerce'; +import {Component, State, h, Element, Prop} from '@stencil/core'; +import { + InitializableComponent, + InitializeBindings, +} from '../../../../utils/initialization-utils'; +import { + ExpandableText, + TruncateAfter, +} from '../../../common/expandable-text/expandable-text'; +import {Hidden} from '../../../common/hidden'; +import {CommerceBindings} from '../../atomic-commerce-interface/atomic-commerce-interface'; +import {ProductContext} from '../product-template-decorators'; + +/** + * @alpha + * The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ +@Component({ + tag: 'atomic-product-excerpt', + shadow: false, +}) +export class AtomicProductExcerpt + implements InitializableComponent +{ + @InitializeBindings() public bindings!: CommerceBindings; + @ProductContext() private product!: Product; + + @Element() hostElement!: HTMLElement; + + public error!: Error; + + @State() private isExpanded = false; + @State() private isTruncated = false; + + private excerptText!: HTMLDivElement; + private resizeObserver: ResizeObserver; + + /** + * The number of lines after which the product excerpt should be truncated. A value of "none" will disable truncation. + */ + @Prop() public truncateAfter: TruncateAfter = '2'; + + /** + * Whether the excerpt should be collapsible after being expanded. + */ + @Prop() public isCollapsible = false; + + constructor() { + this.resizeObserver = new ResizeObserver(() => { + if ( + this.excerptText && + this.excerptText.scrollHeight > this.excerptText.offsetHeight + ) { + this.isTruncated = true; + } else { + this.isTruncated = false; + } + }); + this.validateProps(); + } + + private validateProps() { + new Schema({ + truncateAfter: new StringValue({ + constrainTo: ['none', '1', '2', '3', '4'], + }), + }).validate({ + truncateAfter: this.truncateAfter, + }); + } + + componentDidLoad() { + this.excerptText = this.hostElement.querySelector( + '.expandable-text' + ) as HTMLDivElement; + if (this.excerptText) { + this.resizeObserver.observe(this.excerptText); + } + } + + private onToggleExpand(e?: MouseEvent) { + if (e) { + e.stopPropagation(); + } + + this.isExpanded = !this.isExpanded; + } + + disconnectedCallback() { + this.resizeObserver.disconnect(); + } + + public render() { + const productExcerpt = this.product['excerpt'] ?? null; + + if (!productExcerpt) { + return ; + } + + return ( + this.onToggleExpand(e)} + showMoreLabel={this.bindings.i18n.t('show-more')} + showLessLabel={this.bindings.i18n.t('show-less')} + isCollapsible={this.isCollapsible} + > + + {productExcerpt} + + + ); + } +} diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts new file mode 100644 index 00000000000..4210f88afd3 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts @@ -0,0 +1,142 @@ +import {test, expect} from './fixture'; + +test.describe('atomic-product-excerpt', async () => { + test.beforeEach(async ({page, productExcerpt}) => { + await page.setViewportSize({width: 200, height: 667}); + await productExcerpt.load(); + await productExcerpt.hydrated.first().waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test.describe('when providing an invalid truncate-after value', async () => { + const invalidValues = ['foo', '0', '-1', '5']; + + invalidValues.forEach((value) => { + test(`should return error for value: ${value}`, async ({ + page, + productExcerpt, + }) => { + await productExcerpt.load({ + args: { + // @ts-expect-error needed to test error on invalid value + truncateAfter: value, + }, + }); + + const errorMessage = await page.waitForEvent('console', (msg) => { + return msg.type() === 'error'; + }); + + expect(errorMessage.text()).toContain( + 'truncateAfter: value should be one of: none, 1, 2, 3, 4' + ); + }); + }); + }); + + test('should render excerpt text', async ({productExcerpt}) => { + await productExcerpt.hydrated.first().waitFor(); + + expect(productExcerpt.textContent.first()).toBeVisible(); + }); + + test.describe('when excerpt is truncated', async () => { + const truncateValues: Array<{ + value: '1' | '2' | '3' | '4'; + expectedClass: RegExp; + }> = [ + {value: '1', expectedClass: /line-clamp-1/}, + {value: '2', expectedClass: /line-clamp-2/}, + {value: '3', expectedClass: /line-clamp-3/}, + {value: '4', expectedClass: /line-clamp-4/}, + ]; + + truncateValues.forEach(({value, expectedClass}) => { + test.describe(`when truncateAfter is set to ${value}`, async () => { + test(`should truncate excerpt after ${value} lines`, async ({ + productExcerpt, + }) => { + await productExcerpt.load({ + args: {truncateAfter: value}, + }); + await productExcerpt.hydrated.first().waitFor(); + + const excerptText = productExcerpt.textContent.first(); + expect(excerptText).toHaveClass(expectedClass); + }); + + test('should show "Show More" button', async ({productExcerpt}) => { + const showMoreButton = productExcerpt.showMoreButton.first(); + expect(showMoreButton).toBeVisible(); + }); + + test.describe('when clicking the "Show More" button', async () => { + test.describe('when isCollapsible is true', async () => { + test.beforeEach(async ({productExcerpt}) => { + await productExcerpt.load({ + args: {truncateAfter: value, isCollapsible: true}, + }); + await productExcerpt.hydrated.first().waitFor(); + await productExcerpt.showMoreButton.first().click(); + }); + + test('should expand excerpt', async ({productExcerpt}) => { + const excerptText = productExcerpt.textContent.first(); + expect(excerptText).not.toHaveClass(expectedClass); + }); + + test('should show "Show Less" button', async ({productExcerpt}) => { + const showLessButton = productExcerpt.showLessButton.first(); + expect(showLessButton).toBeVisible(); + }); + + test('should collapse excerpt when clicking the "Show Less" button', async ({ + productExcerpt, + }) => { + const excerptText = productExcerpt.textContent.first(); + await productExcerpt.showLessButton.first().click(); + + expect(excerptText).toHaveClass(expectedClass); + }); + }); + + test.describe('when isCollapsible is false', async () => { + test.beforeEach(async ({productExcerpt}) => { + await productExcerpt.load({ + args: {truncateAfter: value, isCollapsible: false}, + }); + await productExcerpt.hydrated.first().waitFor(); + await productExcerpt.showMoreButton.first().click(); + }); + + test('should expand excerpt', async ({productExcerpt}) => { + const excerptText = productExcerpt.textContent.first(); + expect(excerptText).not.toHaveClass(expectedClass); + }); + + test('should not show "Show Less" button', async ({ + productExcerpt, + }) => { + expect(productExcerpt.showLessButton).not.toBeVisible(); + }); + }); + }); + }); + }); + }); + + test.describe('when excerpt is not truncated', async () => { + test('should hide "Show More" button ', async ({productExcerpt}) => { + await productExcerpt.load({ + args: {truncateAfter: 'none'}, + }); + await productExcerpt.hydrated.first().waitFor(); + + expect(productExcerpt.showMoreButton).not.toBeVisible(); + }); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts new file mode 100644 index 00000000000..f04f298d0cd --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts @@ -0,0 +1,19 @@ +import {test as base} from '@playwright/test'; +import { + makeAxeBuilder, + AxeFixture, +} from '../../../../../../playwright-utils/base-fixture'; +import {ProductExcerptPageObject as ProductExcerpt} from './page-object'; + +type MyFixtures = { + productExcerpt: ProductExcerpt; +}; + +export const test = base.extend({ + makeAxeBuilder, + productExcerpt: async ({page}, use) => { + await use(new ProductExcerpt(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts new file mode 100644 index 00000000000..c2e527ccdc5 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts @@ -0,0 +1,24 @@ +import {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class ProductExcerptPageObject extends BasePageObject<'atomic-product-excerpt'> { + constructor(page: Page) { + super(page, 'atomic-product-excerpt'); + } + + get textContent() { + return this.page.locator('.expandable-text'); + } + + get highlightedText() { + return this.page.locator('atomic-product-text b'); + } + + get showMoreButton() { + return this.page.getByRole('button', {name: 'Show more'}); + } + + get showLessButton() { + return this.page.getByRole('button', {name: 'Show less'}); + } +} diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx new file mode 100644 index 00000000000..f0b94f8530e --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx @@ -0,0 +1,63 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {updateQuery} from '../../../../../../headless/src/features/commerce/query/query-actions'; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: true, +}); + +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-text', + title: 'Atomic-Commerce/Product Template Components/ProductText', + id: 'atomic-product-text', + render: renderComponent, + parameters, + argTypes: { + 'attributes-default': { + name: 'default', + type: 'string', + }, + 'attributes-field': { + name: 'field', + type: 'string', + }, + 'attributes-should-highlight': { + name: 'should-highlight', + type: 'boolean', + }, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-text', + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + ], + play: async (context) => { + await initializeCommerceInterface(context); + + const searchInterface = context.canvasElement.querySelector( + 'atomic-commerce-interface' + ); + searchInterface?.engine?.dispatch(updateQuery({query: 'kayak'})); + + await searchInterface!.executeFirstRequest(); + }, + args: { + 'attributes-field': 'excerpt', + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts new file mode 100644 index 00000000000..4e9f9406913 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts @@ -0,0 +1,95 @@ +import {test, expect} from './fixture'; + +test.describe('default', async () => { + test.beforeEach(async ({productText}) => { + await productText.load(); + await productText.hydrated.first().waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test.describe('when field has no value and default is set', async () => { + test('should render default text', async ({productText}) => { + await productText.load({ + args: {field: 'nonexistentField', default: 'Default Text'}, + }); + await productText.hydrated.first().waitFor(); + + expect(productText.textContent.first()).toContainText('Default Text'); + }); + }); +}); + +test.describe('when using a field that supports highlights', async () => { + const fields = ['excerpt', 'ec_name']; + + fields.forEach((field) => { + test.describe(`when displaying the ${field}`, async () => { + test.beforeEach(async ({productText}) => { + await productText.load({args: {field}}); + await productText.hydrated.first().waitFor(); + }); + + test(`should highlight the keywords in the ${field}`, async ({ + productText, + }) => { + const keywordPattern = /^kayak/i; + + const highlightedText = + await productText.highlightedText.allTextContents(); + + highlightedText.forEach((text) => { + expect(text).toMatch(keywordPattern); + }); + }); + }); + + test(`should not highlight the keywords in the ${field} when shouldHighlight is false`, async ({ + productText, + }) => { + await productText.load({ + args: {field: 'excerpt', shouldHighlight: false}, + }); + await productText.hydrated.first().waitFor(); + + expect(productText.textContent.first()).toContainText(/kayak/i); + + const highlightedText = + await productText.highlightedText.allTextContents(); + expect(highlightedText.length).toEqual(0); + }); + }); +}); + +test.describe('when displaying a field that does not support highlights', async () => { + test.beforeEach(async ({productText}) => { + await productText.load({args: {field: 'ec_description'}}); + await productText.hydrated.first().waitFor(); + }); + + test('should render the field value', async ({productText}) => { + expect(productText.textContent.first()).toBeVisible(); + }); + + test('should not highlight the keywords in the excerpt', async ({ + productText, + }) => { + const highlightedText = await productText.highlightedText.allTextContents(); + expect(productText.textContent.first()).toContainText(/kayak/i); + expect(highlightedText).not.toContain(/kayak/i); + }); +}); + +test.describe('when using a non-string field', async () => { + test.beforeEach(async ({productText, product}) => { + await productText.load({args: {field: 'ec_price'}}); + await product.hydrated.waitFor(); + }); + + test('should not render the field value', async ({productText}) => { + expect(productText.textContent.first()).not.toBeVisible(); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts new file mode 100644 index 00000000000..ff281071361 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts @@ -0,0 +1,24 @@ +import {test as base} from '@playwright/test'; +import { + makeAxeBuilder, + AxeFixture, +} from '../../../../../../playwright-utils/base-fixture'; +import {ProductsPageObject as Product} from '../../../atomic-product/e2e/page-object'; +import {ProductTextPageObject as ProductText} from './page-object'; + +type MyFixtures = { + productText: ProductText; + product: Product; +}; + +export const test = base.extend({ + makeAxeBuilder, + productText: async ({page}, use) => { + await use(new ProductText(page)); + }, + product: async ({page}, use) => { + await use(new Product(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts new file mode 100644 index 00000000000..9db396c5e24 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts @@ -0,0 +1,16 @@ +import {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class ProductTextPageObject extends BasePageObject<'atomic-product-text'> { + constructor(page: Page) { + super(page, 'atomic-product-text'); + } + + get textContent() { + return this.page.locator('atomic-product-text'); + } + + get highlightedText() { + return this.page.locator('atomic-product-text b'); + } +} diff --git a/packages/atomic/src/components/common/expandable-text/expandable-text.tsx b/packages/atomic/src/components/common/expandable-text/expandable-text.tsx new file mode 100644 index 00000000000..5920ac79a9a --- /dev/null +++ b/packages/atomic/src/components/common/expandable-text/expandable-text.tsx @@ -0,0 +1,93 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import MinusIcon from '../../../images/minus.svg'; +import PlusIcon from '../../../images/plus.svg'; +import {Button} from '../button'; + +export type TruncateAfter = 'none' | '1' | '2' | '3' | '4'; + +interface ExpandableTextProps { + isExpanded: boolean; + isTruncated: boolean; + isCollapsible?: boolean; + truncateAfter: TruncateAfter; + onToggleExpand: (e: MouseEvent | undefined) => void; + showMoreLabel: string; + showLessLabel: string; +} + +const getLineClampClass = (truncateAfter: TruncateAfter) => { + const lineClampMap: Record = { + none: 'line-clamp-none', + 1: 'line-clamp-1', + 2: 'line-clamp-2', + 3: 'line-clamp-3', + 4: 'line-clamp-4', + }; + return lineClampMap[truncateAfter] || 'line-clamp-2'; +}; + +const renderShowHideButton = ( + isExpanded: boolean, + isTruncated: boolean, + isCollapsible: boolean, + onToggleExpand: (e?: MouseEvent) => void, + showMoreLabel: string, + showLessLabel: string +) => { + if (!isTruncated && !isExpanded) { + return null; + } + + if (!isCollapsible && !isTruncated && isExpanded) { + return null; + } + + const label = isExpanded ? showLessLabel : showMoreLabel; + + return ( + + ); +}; + +export const ExpandableText: FunctionalComponent = ( + { + isExpanded, + isTruncated, + truncateAfter, + onToggleExpand, + showMoreLabel, + showLessLabel, + isCollapsible = false, + }, + children +) => { + return ( +
+
+ {children} +
+ {renderShowHideButton( + isExpanded, + isTruncated, + isCollapsible, + onToggleExpand, + showMoreLabel, + showLessLabel + )} +
+ ); +}; diff --git a/packages/atomic/src/pages/examples/commerce-website/search.html b/packages/atomic/src/pages/examples/commerce-website/search.html index 3420e6fd93d..0cc0e676b55 100644 --- a/packages/atomic/src/pages/examples/commerce-website/search.html +++ b/packages/atomic/src/pages/examples/commerce-website/search.html @@ -97,7 +97,7 @@

Search page

- +