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',