diff --git a/packages/atomic/cypress/e2e/smart-snippet.cypress.ts b/packages/atomic/cypress/e2e/smart-snippet.cypress.ts index 7b8d432b57f..507b7bb011a 100644 --- a/packages/atomic/cypress/e2e/smart-snippet.cypress.ts +++ b/packages/atomic/cypress/e2e/smart-snippet.cypress.ts @@ -83,25 +83,6 @@ describe('Smart Snippet Test Suites', () => { ); }); - it('when maximumHeight is smaller than collapsedHeight, it should display errors', () => { - const value = 50; - new TestFixture() - .with( - addSmartSnippet({ - props: { - 'maximum-height': value - 1, - 'collapsed-height': value, - }, - }) - ) - .init(); - CommonAssertions.assertConsoleErrorWithoutIt(true); - CommonAssertions.assertContainsComponentErrorWithoutIt( - SmartSnippetSelectors, - true - ); - }); - it('when snippetMaximumHeight is smaller than snippetCollapsedHeight, it should display errors', () => { const value = 50; new TestFixture() @@ -164,46 +145,6 @@ describe('Smart Snippet Test Suites', () => { SmartSnippetAssertions.assertShowLess(false); }); - it('when the snippet height is greater than maximumHeight', () => { - const height = 300; - const heightWhenCollapsed = 150; - - new TestFixture() - .with( - addSmartSnippet({ - snippet: { - ...defaultSnippet, - answer: buildAnswerWithHeight(height), - }, - props: { - 'maximum-height': height - 1, - 'collapsed-height': heightWhenCollapsed, - }, - }) - ) - .init(); - - // before expand - SmartSnippetAssertions.assertShowMore(true); - SmartSnippetAssertions.assertShowLess(false); - SmartSnippetAssertions.assertAnswerHeight(heightWhenCollapsed); - CommonAssertions.assertAccessibility(smartSnippetComponent); - SmartSnippetSelectors.showMoreButton().click(); - - // after expand - SmartSnippetSelectors.body().should('have.attr', 'expanded'); - SmartSnippetAssertions.assertShowMore(false); - SmartSnippetAssertions.assertShowLess(true); - SmartSnippetAssertions.assertAnswerHeight(height); - SmartSnippetSelectors.showLessButton().click(); - - // after collapse - SmartSnippetSelectors.body().should('not.have.attr', 'expanded'); - SmartSnippetAssertions.assertShowMore(true); - SmartSnippetAssertions.assertShowLess(false); - SmartSnippetAssertions.assertAnswerHeight(heightWhenCollapsed); - }); - it('when the snippet height is greater than snippetMaximumHeight', () => { const height = 300; const heightWhenCollapsed = 150; diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 88a945e25fd..0cdf8846cfd 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -1161,22 +1161,6 @@ export namespace Components { "collapsedHeight"?: number; "maximumHeight"?: number; } - interface AtomicSmartSnippetExpandableAnswer { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight": number; - "expanded": boolean; - "htmlContent": string; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight": number; - /** - * Sets the style of the snippet. Example: ```ts expandableAnswer.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - } /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. @@ -1397,10 +1381,6 @@ export interface AtomicSmartSnippetAnswerCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicSmartSnippetAnswerElement; } -export interface AtomicSmartSnippetExpandableAnswerCustomEvent extends CustomEvent { - detail: T; - target: HTMLAtomicSmartSnippetExpandableAnswerElement; -} export interface AtomicSmartSnippetFeedbackModalCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicSmartSnippetFeedbackModalElement; @@ -2012,27 +1992,6 @@ declare global { prototype: HTMLAtomicSmartSnippetCollapseWrapperElement; new (): HTMLAtomicSmartSnippetCollapseWrapperElement; }; - interface HTMLAtomicSmartSnippetExpandableAnswerElementEventMap { - "expand": any; - "collapse": any; - "selectInlineLink": InlineLink; - "beginDelayedSelectInlineLink": InlineLink; - "cancelPendingSelectInlineLink": InlineLink; - } - interface HTMLAtomicSmartSnippetExpandableAnswerElement extends Components.AtomicSmartSnippetExpandableAnswer, HTMLStencilElement { - addEventListener(type: K, listener: (this: HTMLAtomicSmartSnippetExpandableAnswerElement, ev: AtomicSmartSnippetExpandableAnswerCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLAtomicSmartSnippetExpandableAnswerElement, ev: AtomicSmartSnippetExpandableAnswerCustomEvent) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; - } - var HTMLAtomicSmartSnippetExpandableAnswerElement: { - prototype: HTMLAtomicSmartSnippetExpandableAnswerElement; - new (): HTMLAtomicSmartSnippetExpandableAnswerElement; - }; interface HTMLAtomicSmartSnippetFeedbackModalElementEventMap { "feedbackSent": any; } @@ -2220,7 +2179,6 @@ declare global { "atomic-smart-snippet": HTMLAtomicSmartSnippetElement; "atomic-smart-snippet-answer": HTMLAtomicSmartSnippetAnswerElement; "atomic-smart-snippet-collapse-wrapper": HTMLAtomicSmartSnippetCollapseWrapperElement; - "atomic-smart-snippet-expandable-answer": HTMLAtomicSmartSnippetExpandableAnswerElement; "atomic-smart-snippet-feedback-modal": HTMLAtomicSmartSnippetFeedbackModalElement; "atomic-smart-snippet-source": HTMLAtomicSmartSnippetSourceElement; "atomic-smart-snippet-suggestions": HTMLAtomicSmartSnippetSuggestionsElement; @@ -3331,27 +3289,6 @@ declare namespace LocalJSX { "collapsedHeight"?: number; "maximumHeight"?: number; } - interface AtomicSmartSnippetExpandableAnswer { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight"?: number; - "expanded": boolean; - "htmlContent": string; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight"?: number; - "onBeginDelayedSelectInlineLink"?: (event: AtomicSmartSnippetExpandableAnswerCustomEvent) => void; - "onCancelPendingSelectInlineLink"?: (event: AtomicSmartSnippetExpandableAnswerCustomEvent) => void; - "onCollapse"?: (event: AtomicSmartSnippetExpandableAnswerCustomEvent) => void; - "onExpand"?: (event: AtomicSmartSnippetExpandableAnswerCustomEvent) => void; - "onSelectInlineLink"?: (event: AtomicSmartSnippetExpandableAnswerCustomEvent) => void; - /** - * Sets the style of the snippet. Example: ```ts expandableAnswer.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - } /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. @@ -3586,7 +3523,6 @@ declare namespace LocalJSX { "atomic-smart-snippet": AtomicSmartSnippet; "atomic-smart-snippet-answer": AtomicSmartSnippetAnswer; "atomic-smart-snippet-collapse-wrapper": AtomicSmartSnippetCollapseWrapper; - "atomic-smart-snippet-expandable-answer": AtomicSmartSnippetExpandableAnswer; "atomic-smart-snippet-feedback-modal": AtomicSmartSnippetFeedbackModal; "atomic-smart-snippet-source": AtomicSmartSnippetSource; "atomic-smart-snippet-suggestions": AtomicSmartSnippetSuggestions; @@ -3763,7 +3699,6 @@ declare module "@stencil/core" { "atomic-smart-snippet": LocalJSX.AtomicSmartSnippet & JSXBase.HTMLAttributes; "atomic-smart-snippet-answer": LocalJSX.AtomicSmartSnippetAnswer & JSXBase.HTMLAttributes; "atomic-smart-snippet-collapse-wrapper": LocalJSX.AtomicSmartSnippetCollapseWrapper & JSXBase.HTMLAttributes; - "atomic-smart-snippet-expandable-answer": LocalJSX.AtomicSmartSnippetExpandableAnswer & JSXBase.HTMLAttributes; /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts new file mode 100644 index 00000000000..25e104e8ffa --- /dev/null +++ b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts @@ -0,0 +1,335 @@ +import type {i18n} from 'i18next'; +import {html} from 'lit'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {page} from 'vitest/browser'; +import {fixture} from '@/vitest-utils/testing-helpers/fixture'; +import {createTestI18n} from '@/vitest-utils/testing-helpers/i18n-utils'; +import {AtomicSmartSnippetExpandableAnswer} from './atomic-smart-snippet-expandable-answer'; +import './atomic-smart-snippet-expandable-answer'; + +// Mock atomic-smart-snippet-answer to prevent actual rendering +// TODO: uncomment when PR #6781 is merged +// vi.mock( +// '../atomic-smart-snippet-answer/atomic-smart-snippet-answer', +// () => ({}) +// ); + +describe('atomic-smart-snippet-expandable-answer', () => { + async function setElementHeight( + element: AtomicSmartSnippetExpandableAnswer, + height: number + ) { + // biome-ignore lint/suspicious/noExplicitAny: Testing internal state + (element as any).fullHeight = height; + await element.updateComplete; + } + let i18n: i18n; + + beforeEach(async () => { + i18n = await createTestI18n(); + }); + + const renderComponent = async ({ + expanded = false, + htmlContent = '

This is a test answer content

', + maximumHeight = 250, + collapsedHeight = 180, + snippetStyle = undefined, + }: { + expanded?: boolean; + htmlContent?: string; + maximumHeight?: number; + collapsedHeight?: number; + snippetStyle?: string; + } = {}) => { + const element = await fixture(html` + + `); + + // Setup bindings + element.bindings = { + i18n, + // biome-ignore lint/suspicious/noExplicitAny: Mock bindings + } as any; + + await element.initialize(); + await element.updateComplete; + + return { + element, + parts: (el: AtomicSmartSnippetExpandableAnswer) => ({ + truncatedAnswer: el.shadowRoot?.querySelector( + '[part="truncated-answer"]' + ), + showMoreButton: el.shadowRoot?.querySelector( + '[part="show-more-button"]' + ), + showLessButton: el.shadowRoot?.querySelector( + '[part="show-less-button"]' + ), + }), + answer: () => + element.shadowRoot?.querySelector('atomic-smart-snippet-answer')!, + get container() { + return element.shadowRoot?.querySelector('div')!; + }, + get button() { + return element.shadowRoot?.querySelector('button')!; + }, + }; + }; + + describe('initialization', () => { + it('is defined', async () => { + const {element} = await renderComponent(); + expect(element).toBeInstanceOf(AtomicSmartSnippetExpandableAnswer); + }); + + it('should throw error when maximumHeight is less than collapsedHeight', async () => { + const element = await fixture(html` + Test

'} + .maximumHeight=${100} + .collapsedHeight=${200} + >
+ `); + + element.bindings = { + i18n, + // biome-ignore lint/suspicious/noExplicitAny: Mock bindings + } as any; + + expect(() => element.initialize()).toThrow( + 'maximumHeight must be greater than or equal to collapsedHeight' + ); + }); + + it('should not throw error when maximumHeight equals collapsedHeight', async () => { + const element = await fixture(html` + Test

'} + .maximumHeight=${200} + .collapsedHeight=${200} + >
+ `); + + element.bindings = { + i18n, + // biome-ignore lint/suspicious/noExplicitAny: Mock bindings + } as any; + + expect(() => element.initialize()).not.toThrow(); + }); + }); + + describe('rendering', () => { + it('should render the truncated answer part', async () => { + const {parts, element} = await renderComponent(); + expect(parts(element).truncatedAnswer).toBeInTheDocument(); + }); + + it('should render atomic-smart-snippet-answer with correct props', async () => { + const testContent = '

Test content

'; + const testStyle = 'p { color: red; }'; + const {answer} = await renderComponent({ + htmlContent: testContent, + snippetStyle: testStyle, + }); + + const answerElement = answer(); + expect(answerElement).toBeInTheDocument(); + expect(answerElement?.getAttribute('exportparts')).toBe('answer'); + }); + + it('should apply expanded class when expanded prop is true', async () => { + const {container} = await renderComponent({expanded: true}); + expect(container).toHaveClass('expanded'); + }); + + it('should not apply expanded class when expanded prop is false', async () => { + const {element, container} = await renderComponent({expanded: false}); + await setElementHeight(element, 1000); + expect(container).not.toHaveClass('expanded'); + }); + }); + + describe('expand/collapse button', () => { + it('should render show-more-button part when answer height exceeds maximumHeight', async () => { + const {element, parts} = await renderComponent({ + maximumHeight: 250, + }); + + await setElementHeight(element, 300); + await element.requestUpdate(); + await element.updateComplete; + + expect(parts(element).showMoreButton).toBeInTheDocument(); + }); + + it('should not render button when answer height is below maximumHeight', async () => { + const {element, parts} = await renderComponent({ + maximumHeight: 250, + }); + + await setElementHeight(element, 200); + await element.requestUpdate(); + await element.updateComplete; + + expect(parts(element).showMoreButton).not.toBeInTheDocument(); + expect(parts(element).showLessButton).not.toBeInTheDocument(); + }); + + it('should render show-less-button when expanded', async () => { + const {element, parts} = await renderComponent({ + expanded: true, + }); + + await setElementHeight(element, 300); + await element.requestUpdate(); + await element.updateComplete; + + expect(parts(element).showLessButton).toBeInTheDocument(); + }); + }); + + describe('events', () => { + it('should emit expand event when show-more button is clicked', async () => { + const {element} = await renderComponent({expanded: false}); + + await setElementHeight(element, 300); + await element.requestUpdate(); + await element.updateComplete; + + let expandEventFired = false; + element.addEventListener('expand', () => { + expandEventFired = true; + }); + + const button = page.getByRole('button'); + await button.click(); + + expect(expandEventFired).toBe(true); + }); + + it('should emit collapse event when show-less button is clicked', async () => { + const {element} = await renderComponent({expanded: true}); + + await setElementHeight(element, 300); + await element.requestUpdate(); + await element.updateComplete; + + let collapseEventFired = false; + element.addEventListener('collapse', () => { + collapseEventFired = true; + }); + + const button = page.getByRole('button'); + await button.click(); + + expect(collapseEventFired).toBe(true); + }); + + it('should forward selectInlineLink event from atomic-smart-snippet-answer', async () => { + const {element, answer} = await renderComponent(); + + let eventFired = false; + let eventDetail: unknown; + element.addEventListener('selectInlineLink', ((e: CustomEvent) => { + eventFired = true; + eventDetail = e.detail; + }) as EventListener); + + const answerElement = answer(); + answerElement?.dispatchEvent( + new CustomEvent('selectInlineLink', { + detail: {linkText: 'test', linkURL: 'https://test.com'}, + bubbles: true, + }) + ); + + expect(eventFired).toBe(true); + expect(eventDetail).toEqual({ + linkText: 'test', + linkURL: 'https://test.com', + }); + }); + + it('should forward beginDelayedSelectInlineLink event from atomic-smart-snippet-answer', async () => { + const {element, answer} = await renderComponent(); + + let eventFired = false; + element.addEventListener('beginDelayedSelectInlineLink', () => { + eventFired = true; + }); + + const answerElement = answer(); + answerElement?.dispatchEvent( + new CustomEvent('beginDelayedSelectInlineLink', { + detail: {linkText: 'test', linkURL: 'https://test.com'}, + bubbles: true, + }) + ); + + expect(eventFired).toBe(true); + }); + + it('should forward cancelPendingSelectInlineLink event from atomic-smart-snippet-answer', async () => { + const {element, answer} = await renderComponent(); + + let eventFired = false; + element.addEventListener('cancelPendingSelectInlineLink', () => { + eventFired = true; + }); + + const answerElement = answer(); + answerElement?.dispatchEvent( + new CustomEvent('cancelPendingSelectInlineLink', { + detail: {linkText: 'test', linkURL: 'https://test.com'}, + bubbles: true, + }) + ); + + expect(eventFired).toBe(true); + }); + }); + + describe('CSS custom properties', () => { + it('should set --full-height CSS property when fullHeight changes', async () => { + const {element} = await renderComponent(); + + await setElementHeight(element, 300); + await element.requestUpdate(); + await element.updateComplete; + + expect(element.style.getPropertyValue('--full-height')).toBe('300px'); + }); + + it('should set --collapsed-size CSS property when fullHeight changes', async () => { + const {element} = await renderComponent({collapsedHeight: 180}); + + await setElementHeight(element, 300); + await element.requestUpdate(); + await element.updateComplete; + + expect(element.style.getPropertyValue('--collapsed-size')).toBe('180px'); + }); + + it('should set --collapsed-size to fullHeight when button is not shown', async () => { + const {element} = await renderComponent(); + + await setElementHeight(element, 200); + await element.requestUpdate(); + await element.updateComplete; + + expect(element.style.getPropertyValue('--collapsed-size')).toBe('200px'); + }); + }); +}); diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts new file mode 100644 index 00000000000..c1fe771888b --- /dev/null +++ b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts @@ -0,0 +1,242 @@ +import type {InlineLink} from '@coveo/headless'; +import {html, LitElement, type PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; +import type {AnyBindings} from '@/src/components/common/interface/bindings.js'; +import {booleanConverter} from '@/src/converters/boolean-converter.js'; +import {bindingGuard} from '@/src/decorators/binding-guard.js'; +import {bindings} from '@/src/decorators/bindings.js'; +import {errorGuard} from '@/src/decorators/error-guard.js'; +import type {InitializableComponent} from '@/src/decorators/types.js'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js'; +import ArrowDown from '@/src/images/arrow-down.svg'; +import {listenOnce} from '@/src/utils/event-utils.js'; +import styles from './atomic-smart-snippet-expandable-answer.tw.css.js'; +import '@/src/components/common/atomic-icon/atomic-icon.js'; +// TODO: uncomment when PR #6781 is merged +// import '@/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js'; + +/** + * The `atomic-smart-snippet-expandable-answer` component displays an expandable smart snippet answer. + * @internal + * + * @part truncated-answer - The container for the truncated answer content. + * @part show-more-button - The button to expand the answer when collapsed. + * @part show-less-button - The button to collapse the answer when expanded. + */ +@customElement('atomic-smart-snippet-expandable-answer') +@bindings() +@withTailwindStyles +export class AtomicSmartSnippetExpandableAnswer + extends LitElement + implements InitializableComponent +{ + static styles = styles; + + @state() + bindings!: AnyBindings; + + @state() + public error!: Error; + + @property({type: Boolean, reflect: true, converter: booleanConverter}) + expanded!: boolean; + + @property({type: String}) + htmlContent!: string; + + /** + * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. + */ + @property({type: Number, reflect: true}) + maximumHeight = 250; + + /** + * When the answer is partly hidden, how much of its height (in pixels) should be visible. + */ + @property({type: Number, reflect: true}) + collapsedHeight = 180; + + /** + * Sets the style of the snippet. + * + * Example: + * ```ts + * expandableAnswer.snippetStyle = ` + * b { + * color: blue; + * } + * `; + * ``` + */ + @property({type: String}) + snippetStyle?: string; + + @state() + private fullHeight?: number; + + private validateProps() { + if (this.maximumHeight < this.collapsedHeight) { + throw new Error( + 'maximumHeight must be greater than or equal to collapsedHeight' + ); + } + } + + public initialize() { + this.validateProps(); + } + + private get showButton() { + return ( + this.fullHeight !== undefined && this.fullHeight > this.maximumHeight + ); + } + + private get isExpanded() { + return this.expanded || !this.showButton; + } + + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('fullHeight')) { + this.style.setProperty('--full-height', `${this.fullHeight}px`); + this.style.setProperty( + '--collapsed-size', + `${this.showButton ? this.collapsedHeight : this.fullHeight}px` + ); + } + } + + protected async firstUpdated( + _changedProperties: PropertyValues + ): Promise { + super.firstUpdated(_changedProperties); + this.fullHeight = await this.establishInitialHeight(); + } + + private async establishInitialHeight(): Promise { + const answerElement = document.createElement( + 'atomic-smart-snippet-answer' + ) as HTMLElement & {htmlContent: string; innerStyle?: string}; + answerElement.htmlContent = this.htmlContent; + answerElement.innerStyle = this.snippetStyle; + answerElement.style.visibility = 'hidden'; + answerElement.style.position = 'absolute'; + + return new Promise((resolve) => { + listenOnce(answerElement, 'answerSizeUpdated', (event) => { + answerElement.remove(); + resolve((event as CustomEvent<{height: number}>).detail.height); + }); + this.parentElement!.appendChild(answerElement); + }); + } + + private handleExpand() { + this.dispatchEvent( + new CustomEvent('expand', { + bubbles: true, + composed: true, + }) + ); + } + + private handleCollapse() { + this.dispatchEvent( + new CustomEvent('collapse', { + bubbles: true, + composed: true, + }) + ); + } + + private handleSelectInlineLink(e: CustomEvent) { + this.dispatchEvent( + new CustomEvent('selectInlineLink', { + detail: e.detail, + bubbles: true, + composed: true, + }) + ); + } + + private handleBeginDelayedSelectInlineLink(e: CustomEvent) { + this.dispatchEvent( + new CustomEvent('beginDelayedSelectInlineLink', { + detail: e.detail, + bubbles: true, + composed: true, + }) + ); + } + + private handleCancelPendingSelectInlineLink(e: CustomEvent) { + this.dispatchEvent( + new CustomEvent('cancelPendingSelectInlineLink', { + detail: e.detail, + bubbles: true, + composed: true, + }) + ); + } + + private renderAnswer() { + return html` +
+ ) => { + this.fullHeight = e.detail.height; + }} + @selectInlineLink=${this.handleSelectInlineLink} + @beginDelayedSelectInlineLink=${ + this.handleBeginDelayedSelectInlineLink + } + @cancelPendingSelectInlineLink=${ + this.handleCancelPendingSelectInlineLink + } + > +
+ `; + } + + private renderButton() { + return when( + this.showButton, + () => html` + + ` + ); + } + + @errorGuard() + @bindingGuard() + render() { + return html` +
+ ${this.renderAnswer()} ${this.renderButton()} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-smart-snippet-expandable-answer': AtomicSmartSnippetExpandableAnswer; + } +} diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.tw.css.ts b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.tw.css.ts new file mode 100644 index 00000000000..c19faae3c3a --- /dev/null +++ b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.tw.css.ts @@ -0,0 +1,48 @@ +import {css} from 'lit'; + +const styles = css` + @reference '../../../utils/tailwind.global.tw.css'; + @reference '../../../utils/tailwind-utilities/set-font-size.css'; + atomic-smart-snippet-answer { + @apply set-font-size-lg; + + display: block; + overflow: hidden; + height: var(--collapsed-size); + + --gradient-start: var( + --atomic-smart-snippet-gradient-start, + calc( + max( + var(--collapsed-size) - (var(--line-height) * 1.5), + var(--collapsed-size) * 0.5 + ) + ) + ); + color: var(--atomic-on-background); + mask-image: linear-gradient( + black, + black var(--gradient-start), + transparent 100% + ); + } + + atomic-smart-snippet-answer.loaded { + transition: height ease-in-out 0.25s; + } + + button atomic-icon { + @apply relative top-0.5; + } + + .expanded atomic-smart-snippet-answer { + height: var(--full-height); + mask-image: none; + } + + .expanded button atomic-icon { + @apply top-0 -scale-y-100; + } +`; + +export default styles; diff --git a/packages/atomic/src/components/common/index.ts b/packages/atomic/src/components/common/index.ts index 9a310098633..846ee77fa25 100644 --- a/packages/atomic/src/components/common/index.ts +++ b/packages/atomic/src/components/common/index.ts @@ -8,4 +8,5 @@ export {AtomicIcon} from './atomic-icon/atomic-icon.js'; export {AtomicLayoutSection} from './atomic-layout-section/atomic-layout-section.js'; export {AtomicModal} from './atomic-modal/atomic-modal.js'; export {AtomicNumericRange} from './atomic-numeric-range/atomic-numeric-range.js'; +export {AtomicSmartSnippetExpandableAnswer} from './atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.js'; export {AtomicTabBar} from './atomic-tab-bar/atomic-tab-bar.js'; diff --git a/packages/atomic/src/components/common/lazy-index.ts b/packages/atomic/src/components/common/lazy-index.ts index 76abbba2a5e..cc96286f564 100644 --- a/packages/atomic/src/components/common/lazy-index.ts +++ b/packages/atomic/src/components/common/lazy-index.ts @@ -16,6 +16,10 @@ export default { 'atomic-modal': async () => await import('./atomic-modal/atomic-modal.js'), 'atomic-numeric-range': async () => await import('./atomic-numeric-range/atomic-numeric-range.js'), + 'atomic-smart-snippet-expandable-answer': async () => + await import( + './atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.js' + ), 'atomic-tab-bar': async () => await import('./atomic-tab-bar/atomic-tab-bar.js'), } as Record Promise>; diff --git a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.pcss b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.pcss deleted file mode 100644 index 89a77991387..00000000000 --- a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.pcss +++ /dev/null @@ -1,41 +0,0 @@ -@import '../../../../global/global.pcss'; -@reference '../../../../utils/tailwind-utilities/set-font-size.css'; - -/** - * @prop --atomic-smart-snippet-gradient-start: At which height to start fading out a truncated answer. - */ -:host { - atomic-smart-snippet-answer { - @apply set-font-size-lg; - - display: block; - overflow: hidden; - height: var(--collapsed-size); - - --gradient-start: var( - --atomic-smart-snippet-gradient-start, - calc(max(var(--collapsed-size) - (var(--line-height) * 1.5), var(--collapsed-size) * 0.5)) - ); - @apply text-on-background; - mask-image: linear-gradient(black, black var(--gradient-start), transparent 100%); - - &.loaded { - transition: height ease-in-out 0.25s; - } - } - - button atomic-icon { - @apply relative top-0.5; - } - - .expanded { - atomic-smart-snippet-answer { - height: var(--full-height); - mask-image: none; - } - - button atomic-icon { - @apply top-0 -scale-y-100; - } - } -} diff --git a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.tsx b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.tsx deleted file mode 100644 index 586a31d7548..00000000000 --- a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import {InlineLink} from '@coveo/headless'; -import { - h, - Component, - State, - Prop, - Element, - Watch, - Event, - EventEmitter, -} from '@stencil/core'; -import ArrowDown from '../../../../images/arrow-down.svg'; -import {listenOnce} from '../../../../utils/event-utils'; -import {InitializeBindings} from '../../../../utils/initialization-utils'; -import {AnyBindings} from '../../interface/bindings'; - -/** - * @internal - */ -@Component({ - tag: 'atomic-smart-snippet-expandable-answer', - styleUrl: 'atomic-smart-snippet-expandable-answer.pcss', - shadow: true, -}) -export class AtomicSmartSnippetExpandableAnswer { - @InitializeBindings() public bindings!: AnyBindings; - public error!: Error; - @Element() public host!: HTMLElement; - - @Prop({reflect: true}) expanded!: boolean; - @Prop() htmlContent!: string; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - @Prop({reflect: true}) maximumHeight = 250; - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - @Prop({reflect: true}) collapsedHeight = 180; - /** - * Sets the style of the snippet. - * - * Example: - * ```ts - * expandableAnswer.snippetStyle = ` - * b { - * color: blue; - * } - * `; - * ``` - */ - @Prop() snippetStyle?: string; - - @State() fullHeight?: number; - - @Event() expand!: EventEmitter; - @Event() collapse!: EventEmitter; - @Event() selectInlineLink!: EventEmitter; - @Event() beginDelayedSelectInlineLink!: EventEmitter; - @Event() cancelPendingSelectInlineLink!: EventEmitter; - - private validateProps() { - if (this.maximumHeight < this.collapsedHeight) { - throw 'maximumHeight must be equal or greater than collapsedHeight'; - } - } - - public initialize() { - this.validateProps(); - } - - @Watch('fullHeight') - public fullHeightUpdated() { - this.host.style.setProperty('--full-height', `${this.fullHeight}px`); - this.host.style.setProperty( - '--collapsed-size', - `${this.showButton ? this.collapsedHeight : this.fullHeight}px` - ); - } - - private establishInitialHeight() { - const answerElement = document.createElement('atomic-smart-snippet-answer'); - answerElement.htmlContent = this.htmlContent; - answerElement.innerStyle = this.snippetStyle; - answerElement.style.visibility = 'hidden'; - answerElement.style.position = 'absolute'; - return new Promise((resolve) => { - listenOnce(answerElement, 'answerSizeUpdated', (event) => { - answerElement.remove(); - resolve((event as CustomEvent<{height: number}>).detail.height); - }); - this.host.parentElement!.appendChild(answerElement); - }); - } - - private get showButton() { - return this.fullHeight! > this.maximumHeight; - } - - private get isExpanded() { - return this.expanded || !this.showButton; - } - - public async componentWillLoad() { - this.fullHeight = await this.establishInitialHeight(); - } - - public renderAnswer() { - return ( -
- (this.fullHeight = e.detail.height)} - onSelectInlineLink={(e) => this.selectInlineLink.emit(e.detail)} - onBeginDelayedSelectInlineLink={(e) => - this.beginDelayedSelectInlineLink.emit(e.detail) - } - onCancelPendingSelectInlineLink={(e) => - this.cancelPendingSelectInlineLink.emit(e.detail) - } - > -
- ); - } - - public renderButton() { - if (!this.showButton) { - return; - } - return ( - - ); - } - - public render() { - return ( -
- {this.renderAnswer()} - {this.renderButton()} -
- ); - } -} diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index b7333e63a6e..42d6f0dd35b 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -133,6 +133,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-search-interface', 'atomic-search-layout', 'atomic-segmented-facet-scrollable', + 'atomic-smart-snippet-expandable-answer', 'atomic-sort-dropdown', 'atomic-sort-expression', 'atomic-tab',