;
/**
* @deprecated Use `atomic-facet-date-input` instead. This component is meant to be used with Stencil components only.
* Internal component made to be integrated in a TimeframeFacet.
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
index 25e104e8ffa..10af031e43f 100644
--- 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
@@ -1,7 +1,6 @@
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';
@@ -202,7 +201,7 @@ describe('atomic-smart-snippet-expandable-answer', () => {
describe('events', () => {
it('should emit expand event when show-more button is clicked', async () => {
- const {element} = await renderComponent({expanded: false});
+ const {element, parts} = await renderComponent({expanded: false});
await setElementHeight(element, 300);
await element.requestUpdate();
@@ -213,14 +212,14 @@ describe('atomic-smart-snippet-expandable-answer', () => {
expandEventFired = true;
});
- const button = page.getByRole('button');
- await button.click();
+ const showMoreButton = parts(element).showMoreButton as HTMLElement;
+ showMoreButton.click();
expect(expandEventFired).toBe(true);
});
it('should emit collapse event when show-less button is clicked', async () => {
- const {element} = await renderComponent({expanded: true});
+ const {element, parts} = await renderComponent({expanded: true});
await setElementHeight(element, 300);
await element.requestUpdate();
@@ -231,8 +230,8 @@ describe('atomic-smart-snippet-expandable-answer', () => {
collapseEventFired = true;
});
- const button = page.getByRole('button');
- await button.click();
+ const showLessButton = parts(element).showLessButton as HTMLElement;
+ showLessButton.click();
expect(collapseEventFired).toBe(true);
});
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
index c1fe771888b..dbdd6fddd54 100644
--- 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
@@ -13,6 +13,7 @@ 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';
+import '@/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js';
// TODO: uncomment when PR #6781 is merged
// import '@/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js';
diff --git a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts
index 0e0546a2d0a..9e34450ad22 100644
--- a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts
+++ b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts
@@ -1,6 +1,7 @@
import {html} from 'lit';
import {ifDefined} from 'lit/directives/if-defined.js';
import type {FunctionalComponent} from '@/src/utils/functional-component-utils';
+import '@/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js';
export interface SnippetTruncatedAnswerProps {
answer: string;
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx
index 363ff0da8fe..39331bf7821 100644
--- a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx
+++ b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx
@@ -162,7 +162,7 @@ const {decorator, play} = wrapInSearchInterface({
const meta: Meta = {
component: 'atomic-smart-snippet-suggestions',
- title: 'Search/SmartSnippet/SmartSnippetSuggestions',
+ title: 'Search/Smart Snippet Suggestions',
id: 'atomic-smart-snippet-suggestions',
render: (args) => template(args),
decorators: [decorator],
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts
index 261424434e6..77df7361ce7 100644
--- a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts
+++ b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts
@@ -28,6 +28,7 @@ import ArrowDown from '@/src/images/arrow-down.svg';
import ArrowRight from '@/src/images/arrow-right.svg';
import {randomID} from '@/src/utils/utils';
import '@/src/components/common/atomic-icon/atomic-icon';
+import '@/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js';
import styles from './atomic-smart-snippet-suggestions.tw.css';
/**
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx
new file mode 100644
index 00000000000..0151b0d5854
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx
@@ -0,0 +1,136 @@
+import { Meta } from '@storybook/addon-docs/blocks';
+import * as AtomicSmartSnippetStories from './atomic-smart-snippet.new.stories';
+import { AtomicDocTemplate } from '@/storybook-utils/documentation/atomic-doc-template';
+
+
+
+
+
+
+The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. It provides users with quick answers directly in the search results.
+
+This component is used within the `atomic-search-interface`:
+
+```html
+
+
+
+
+
+
+
+
+```
+
+## Features
+
+- **Question Display**: Shows the question that the snippet answers
+- **Answer Excerpt**: Displays the relevant portion of the document with highlighted terms
+- **Source Information**: Links to the source document (URL and title)
+- **User Feedback**: Allows users to like or dislike the answer
+- **Expandable Content**: Shows/hides full answer based on configured heights
+
+## Basic Usage
+
+To add a smart snippet to your interface:
+
+```html
+
+```
+
+## Customization
+
+### Heading Level
+
+Set the heading level for the question (from 1 to 5):
+
+```html
+
+```
+
+### Height Configuration
+
+Control when the "show more/less" buttons appear:
+
+```html
+
+```
+
+- `maximum-height`: Maximum height (in pixels) before the component truncates and shows "show more"
+- `collapsed-height`: How much of the answer's height (in pixels) is visible when collapsed
+
+### Custom Styling
+
+You can style the snippet content using a template element:
+
+```html
+
+
+
+
+
+```
+
+Or use the `snippet-style` attribute:
+
+```html
+
+```
+
+### Tab Filtering
+
+Display the smart snippet only on specific tabs:
+
+```html
+
+
+
+
+
+```
+
+**Note**: Don't use both `tabs-included` and `tabs-excluded` at the same time.
+
+### Source Link Attributes
+
+Pass custom attributes to the source links using slots:
+
+```html
+
+
+
+```
+
+## User Feedback
+
+The component includes a feedback mechanism that allows users to:
+- Like the answer (helpful)
+- Dislike the answer (not helpful)
+- Provide detailed feedback when disliking (opens a modal)
+
+The feedback is automatically tracked via analytics.
+
+## Behavior
+
+- The component only appears when a smart snippet answer is found for the query
+- Inline links in the answer are tracked when clicked
+- The answer can be expanded/collapsed if it exceeds the configured height
+- User feedback state is preserved within the same search session
+
+
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx
new file mode 100644
index 00000000000..31d049baafb
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx
@@ -0,0 +1,79 @@
+import type {Result} from '@coveo/headless';
+import type {Meta, StoryObj as Story} from '@storybook/web-components-vite';
+import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers';
+import {MockSearchApi} from '@/storybook-utils/api/search/mock';
+import {parameters} from '@/storybook-utils/common/common-meta-parameters';
+import {wrapInSearchInterface} from '@/storybook-utils/search/search-interface-wrapper';
+
+const mockSearchApi = new MockSearchApi();
+
+const {events, args, argTypes, template} = getStorybookHelpers(
+ 'atomic-smart-snippet',
+ {excludeCategories: ['methods']}
+);
+
+const {decorator, play} = wrapInSearchInterface();
+
+const meta: Meta = {
+ component: 'atomic-smart-snippet',
+ title: 'Search/Smart Snippet',
+ id: 'atomic-smart-snippet',
+ render: (args) => template(args),
+ decorators: [decorator],
+ parameters: {
+ ...parameters,
+ actions: {
+ handles: events,
+ },
+ msw: {
+ handlers: [...mockSearchApi.handlers],
+ },
+ },
+ args,
+ argTypes,
+ beforeEach: async () => {
+ mockSearchApi.searchEndpoint.clear();
+ mockSearchApi.searchEndpoint.mockOnce((response) => {
+ if (!('results' in response)) return response;
+ const [result] = response.results as Result[];
+ return {
+ ...response,
+ results: [
+ {
+ ...result,
+ title: 'Manage the Coveo In-Product Experiences (IPX)',
+ clickUri: 'https://docs.coveo.com/en/3160',
+ },
+ ...response.results.slice(1),
+ ],
+ questionAnswer: {
+ answerFound: true,
+ documentId: {
+ contentIdKey: 'permanentid',
+ contentIdValue: result.raw.permanentid,
+ },
+ question: 'Creating an In-Product Experience (IPX)',
+ answerSnippet: `
+
+ - On the In-Product Experiences page, click Add In-Product Experience.
+ - In the Configuration tab, fill the Basic settings section.
+ - (Optional) Use the Design and Content access tabs to customize your IPX interface.
+ - Click Save.
+ - In the Loader snippet panel that appears, you may click Copy to save the loader snippet for your IPX to your clipboard, and then click Save. You can always retrieve the loader snippet later.
+
+
+ You're now ready to embed your IPX interface. However, we recommend that you configure query pipelines for your IPX interface before.
+
+ `,
+ relatedQuestions: [],
+ score: 1337,
+ },
+ };
+ });
+ },
+ play,
+};
+
+export default meta;
+
+export const Default: Story = {};
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts
new file mode 100644
index 00000000000..7b7a459665f
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts
@@ -0,0 +1,485 @@
+import {
+ buildSmartSnippet,
+ buildTabManager,
+ type InlineLink,
+ type SmartSnippet,
+ type TabManager,
+} from '@coveo/headless';
+import {html} from 'lit';
+import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture';
+import {buildFakeSmartSnippet} from '@/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller';
+import {buildFakeTabManager} from '@/vitest-utils/testing-helpers/fixtures/headless/search/tab-manager-controller';
+import type {AtomicSmartSnippet} from './atomic-smart-snippet';
+
+import './atomic-smart-snippet';
+
+vi.mock('@coveo/headless', {spy: true});
+
+describe('atomic-smart-snippet', () => {
+ let mockedSmartSnippet: SmartSnippet;
+ let mockedTabManager: TabManager;
+
+ beforeEach(() => {
+ mockedSmartSnippet = buildFakeSmartSnippet({
+ state: {
+ answerFound: true,
+ question: 'What is the meaning of life?',
+ answer:
+ 'The answer is 42. Link 1 and Link 2
',
+ source: {
+ title: "The Hitchhiker's Guide to the Galaxy",
+ clickUri: 'https://example.com/guide',
+ uniqueId: 'guide-123',
+ raw: {},
+ } as unknown as NonNullable<
+ ReturnType['state']['source']
+ >,
+ liked: false,
+ disliked: false,
+ expanded: false,
+ },
+ });
+ mockedTabManager = buildFakeTabManager({});
+ });
+
+ const renderAtomicSmartSnippet = async ({
+ props = {},
+ }: {
+ props?: Partial<{
+ headingLevel: number;
+ maximumHeight: number;
+ collapsedHeight: number;
+ snippetStyle: string;
+ tabsIncluded: string[];
+ tabsExcluded: string[];
+ snippetMaximumHeight: number;
+ snippetCollapsedHeight: number;
+ }>;
+ } = {}) => {
+ vi.mocked(buildSmartSnippet).mockReturnValue(mockedSmartSnippet);
+ vi.mocked(buildTabManager).mockReturnValue(mockedTabManager);
+
+ const {element} = await renderInAtomicSearchInterface({
+ template: html``,
+ selector: 'atomic-smart-snippet',
+ });
+
+ const getParts = () => ({
+ smartSnippet: element.shadowRoot?.querySelector(
+ '[part~="smart-snippet"]'
+ ),
+ question: element.shadowRoot?.querySelector('[part~="question"]'),
+ answer: element.shadowRoot?.querySelector('atomic-smart-snippet-answer'),
+ truncatedAnswer: element.shadowRoot?.querySelector(
+ '[part~="truncated-answer"]'
+ ),
+ body: element.shadowRoot?.querySelector('[part~="body"]'),
+ footer: element.shadowRoot?.querySelector('[part~="footer"]'),
+ feedbackBanner: element.shadowRoot?.querySelector(
+ '[part~="feedback-banner"]'
+ ),
+ feedbackLikeButton: element.shadowRoot?.querySelector(
+ '[part~="feedback-like-button"]'
+ ) as HTMLElement | null,
+ feedbackDislikeButton: element.shadowRoot?.querySelector(
+ '[part~="feedback-dislike-button"]'
+ ) as HTMLElement | null,
+ feedbackThankYou: element.shadowRoot?.querySelector(
+ '[part~="feedback-thank-you"]'
+ ),
+ expandableAnswer: element.shadowRoot?.querySelector(
+ 'atomic-smart-snippet-expandable-answer'
+ ),
+ source: element.shadowRoot?.querySelector('atomic-smart-snippet-source'),
+ sourceUrl: element.shadowRoot?.querySelector('[part~="source-url"]'),
+ sourceTitle: element.shadowRoot?.querySelector('[part~="source-title"]'),
+ });
+
+ return {element, getParts};
+ };
+
+ describe('when controller is initialized', () => {
+ it('should call buildSmartSnippet with the engine', async () => {
+ const {element} = await renderAtomicSmartSnippet();
+ expect(buildSmartSnippet).toHaveBeenCalledWith(element.bindings.engine);
+ });
+
+ it('should call buildTabManager with the engine', async () => {
+ const {element} = await renderAtomicSmartSnippet();
+ expect(buildTabManager).toHaveBeenCalledWith(element.bindings.engine);
+ });
+ });
+
+ describe('when answer is found', () => {
+ beforeEach(() => {
+ mockedSmartSnippet.state.answerFound = true;
+ });
+
+ it('should render the smart snippet', async () => {
+ const {getParts} = await renderAtomicSmartSnippet();
+ expect(getParts().smartSnippet).toBeInTheDocument();
+ });
+
+ it('should render the question', async () => {
+ const {getParts} = await renderAtomicSmartSnippet();
+ const question = getParts().question!;
+ expect(question).toBeInTheDocument();
+ expect(question.textContent?.trim()).toBe(
+ mockedSmartSnippet.state.question
+ );
+ });
+
+ it('should render the expandable answer when snippetMaximumHeight is undefined', async () => {
+ const {getParts} = await renderAtomicSmartSnippet({
+ props: {snippetMaximumHeight: undefined},
+ });
+ expect(getParts().expandableAnswer).toBeInTheDocument();
+ });
+
+ it('should render the truncated answer when snippetMaximumHeight is defined', async () => {
+ const {getParts} = await renderAtomicSmartSnippet({
+ props: {snippetMaximumHeight: 200},
+ });
+ expect(getParts().truncatedAnswer).toBeInTheDocument();
+ });
+
+ it('should render the footer', async () => {
+ const {getParts} = await renderAtomicSmartSnippet();
+ expect(getParts().footer).toBeInTheDocument();
+ });
+
+ it('should render the source when source is present', async () => {
+ const {getParts} = await renderAtomicSmartSnippet();
+ expect(getParts().source).toBeInTheDocument();
+ });
+
+ it('should render the feedback banner', async () => {
+ const {getParts} = await renderAtomicSmartSnippet();
+ expect(getParts().feedbackBanner).toBeInTheDocument();
+ });
+
+ // TODO: Enable when atomic-smart-snippet-source is migrated to Lit
+ it.skip('should render source url and title with correct href', async () => {
+ const {getParts} = await renderAtomicSmartSnippet();
+ const {sourceUrl, sourceTitle} = getParts();
+
+ expect(sourceUrl).toBeInTheDocument();
+ expect(sourceTitle).toBeInTheDocument();
+ expect(sourceUrl?.getAttribute('href')).toBe(
+ mockedSmartSnippet.state.source?.clickUri
+ );
+ expect(sourceTitle?.getAttribute('href')).toBe(
+ mockedSmartSnippet.state.source?.clickUri
+ );
+ });
+ });
+
+ it('should not render the smart snippet when answer is not found', async () => {
+ mockedSmartSnippet.state.answerFound = false;
+ const {getParts} = await renderAtomicSmartSnippet();
+ expect(getParts().smartSnippet).not.toBeInTheDocument();
+ });
+
+ it('should not render the source when source is not present', async () => {
+ // @ts-expect-error: Testing null source
+ mockedSmartSnippet.state.source = null;
+ const {getParts} = await renderAtomicSmartSnippet();
+ expect(getParts().source).toBeNull();
+ });
+
+ describe('tab filtering', () => {
+ describe('when tabsIncluded is set', () => {
+ it('should render when current tab is included', async () => {
+ mockedTabManager.state.activeTab = 'tab1';
+ const {getParts} = await renderAtomicSmartSnippet({
+ props: {tabsIncluded: ['tab1', 'tab2']},
+ });
+ expect(getParts().smartSnippet).toBeInTheDocument();
+ });
+
+ it('should not render when current tab is not included', async () => {
+ mockedTabManager.state.activeTab = 'tab3';
+ const {getParts} = await renderAtomicSmartSnippet({
+ props: {tabsIncluded: ['tab1', 'tab2']},
+ });
+ expect(getParts().smartSnippet).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when tabsExcluded is set', () => {
+ it('should not render when current tab is excluded', async () => {
+ mockedTabManager.state.activeTab = 'tab1';
+ const {getParts} = await renderAtomicSmartSnippet({
+ props: {tabsExcluded: ['tab1', 'tab2']},
+ });
+ expect(getParts().smartSnippet).not.toBeInTheDocument();
+ });
+
+ it('should render when current tab is not excluded', async () => {
+ mockedTabManager.state.activeTab = 'tab3';
+ const {getParts} = await renderAtomicSmartSnippet({
+ props: {tabsExcluded: ['tab1', 'tab2']},
+ });
+ expect(getParts().smartSnippet).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('feedback functionality', () => {
+ // TODO: Enable when feedback button selectors are fixed
+ it.skip('should call smartSnippet.like() when like button is clicked', async () => {
+ const likeSpy = vi.spyOn(mockedSmartSnippet, 'like');
+ const {getParts} = await renderAtomicSmartSnippet();
+ getParts().feedbackLikeButton?.click();
+ expect(likeSpy).toHaveBeenCalled();
+ });
+
+ // TODO: Enable when feedback button selectors are fixed
+ it.skip('should call smartSnippet.dislike() when dislike button is clicked', async () => {
+ const dislikeSpy = vi.spyOn(mockedSmartSnippet, 'dislike');
+ const {getParts} = await renderAtomicSmartSnippet();
+ getParts().feedbackDislikeButton?.click();
+ expect(dislikeSpy).toHaveBeenCalled();
+ });
+
+ // TODO: Enable when feedback button selectors are fixed
+ it.skip('should load modal when dislike button is clicked', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet();
+ getParts().feedbackDislikeButton?.click();
+
+ await vi.waitFor(() => {
+ const modal = element
+ .getRootNode()
+ .querySelector('atomic-smart-snippet-feedback-modal');
+ expect(modal).not.toBeNull();
+ });
+ });
+
+ // TODO: Enable when feedback button selectors are fixed
+ it.skip('should show thank you message after liking', async () => {
+ mockedSmartSnippet.state.liked = false;
+ const {element, getParts} = await renderAtomicSmartSnippet();
+
+ getParts().feedbackLikeButton?.click();
+ mockedSmartSnippet.state.liked = true;
+ element.requestUpdate();
+ await element.updateComplete;
+
+ expect(getParts().feedbackThankYou).toBeInTheDocument();
+ });
+
+ // TODO: Enable when feedback button selectors are fixed
+ it.skip('should show thank you message after disliking', async () => {
+ mockedSmartSnippet.state.disliked = false;
+ const {element, getParts} = await renderAtomicSmartSnippet();
+
+ getParts().feedbackDislikeButton?.click();
+ mockedSmartSnippet.state.disliked = true;
+ element.requestUpdate();
+ await element.updateComplete;
+
+ expect(getParts().feedbackThankYou).toBeInTheDocument();
+ });
+
+ it('should hide thank you message when liked state changes to false', async () => {
+ mockedSmartSnippet.state.liked = true;
+ const {element, getParts} = await renderAtomicSmartSnippet();
+ element.requestUpdate();
+
+ await element.updateComplete;
+ expect(getParts().feedbackThankYou).toBeInTheDocument();
+
+ mockedSmartSnippet.state.liked = false;
+
+ element.requestUpdate();
+
+ await element.updateComplete;
+ expect(getParts().feedbackThankYou).not.toBeInTheDocument();
+ });
+ });
+
+ describe('inline link events', () => {
+ it('should call smartSnippet.selectInlineLink() when selectInlineLink event is dispatched', async () => {
+ const selectInlineLinkSpy = vi.spyOn(
+ mockedSmartSnippet,
+ 'selectInlineLink'
+ );
+ const {element} = await renderAtomicSmartSnippet();
+ const inlineLink: InlineLink = {
+ linkText: 'test',
+ linkURL: 'https://example.com',
+ };
+ element.dispatchEvent(
+ new CustomEvent('selectInlineLink', {detail: inlineLink})
+ );
+ expect(selectInlineLinkSpy).toHaveBeenCalledWith(inlineLink);
+ });
+
+ it('should call smartSnippet.beginDelayedSelectInlineLink() when beginDelayedSelectInlineLink event is dispatched', async () => {
+ const beginDelayedSelectInlineLinkSpy = vi.spyOn(
+ mockedSmartSnippet,
+ 'beginDelayedSelectInlineLink'
+ );
+ const {element} = await renderAtomicSmartSnippet();
+ const inlineLink: InlineLink = {
+ linkText: 'test',
+ linkURL: 'https://example.com',
+ };
+ element.dispatchEvent(
+ new CustomEvent('beginDelayedSelectInlineLink', {detail: inlineLink})
+ );
+ expect(beginDelayedSelectInlineLinkSpy).toHaveBeenCalledWith(inlineLink);
+ });
+
+ it('should call smartSnippet.cancelPendingSelectInlineLink() when cancelPendingSelectInlineLink event is dispatched', async () => {
+ const cancelPendingSelectInlineLinkSpy = vi.spyOn(
+ mockedSmartSnippet,
+ 'cancelPendingSelectInlineLink'
+ );
+ const {element} = await renderAtomicSmartSnippet();
+ const inlineLink: InlineLink = {
+ linkText: 'test',
+ linkURL: 'https://example.com',
+ };
+ element.dispatchEvent(
+ new CustomEvent('cancelPendingSelectInlineLink', {detail: inlineLink})
+ );
+ expect(cancelPendingSelectInlineLinkSpy).toHaveBeenCalledWith(inlineLink);
+ });
+ });
+
+ describe('expandable answer integration', () => {
+ it('should call smartSnippet.expand() when expand event is dispatched', async () => {
+ const expandSpy = vi.spyOn(mockedSmartSnippet, 'expand');
+ const {getParts} = await renderAtomicSmartSnippet();
+ getParts().expandableAnswer?.dispatchEvent(new CustomEvent('expand'));
+ expect(expandSpy).toHaveBeenCalled();
+ });
+
+ it('should call smartSnippet.collapse() when collapse event is dispatched', async () => {
+ const collapseSpy = vi.spyOn(mockedSmartSnippet, 'collapse');
+ const {getParts} = await renderAtomicSmartSnippet();
+ getParts().expandableAnswer?.dispatchEvent(new CustomEvent('collapse'));
+ expect(collapseSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('props', () => {
+ it('should pass headingLevel prop to question renderer', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet({
+ props: {headingLevel: 2},
+ });
+ expect(element.headingLevel).toBe(2);
+ expect(getParts().question).toBeInTheDocument();
+ });
+
+ it('should use heading level 0 when no heading level is specified', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet({
+ props: {headingLevel: 0},
+ });
+ expect(element.headingLevel).toBe(0);
+ expect(getParts().question).toBeInTheDocument();
+
+ const question = getParts().question!;
+ expect(question.tagName).toBe('DIV');
+ });
+
+ it('should pass maximumHeight prop to expandable answer', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet({
+ props: {maximumHeight: 300},
+ });
+ expect(element.maximumHeight).toBe(300);
+ expect(getParts().expandableAnswer).toBeInTheDocument();
+ });
+
+ it('should pass collapsedHeight prop to expandable answer', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet({
+ props: {collapsedHeight: 150},
+ });
+ expect(element.collapsedHeight).toBe(150);
+ expect(getParts().expandableAnswer).toBeInTheDocument();
+ });
+
+ it('should accept snippetStyle prop', async () => {
+ const customStyle = 'b { color: blue; }';
+ const {element} = await renderAtomicSmartSnippet({
+ props: {snippetStyle: customStyle},
+ });
+ expect(element.snippetStyle).toBe(customStyle);
+ });
+
+ it('should use snippetStyle attribute when no template is present', async () => {
+ const customStyle = 'b { color: green; }';
+ const {element} = await renderAtomicSmartSnippet({
+ props: {snippetStyle: customStyle},
+ });
+
+ // The snippetStyle property should contain the style, not element.style
+ expect(element.snippetStyle).toBe(customStyle);
+ });
+ });
+
+ it('should remove event listeners when disconnected', async () => {
+ const {element} = await renderAtomicSmartSnippet();
+ const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener');
+ element.disconnectedCallback();
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'selectInlineLink',
+ expect.any(Function)
+ );
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'beginDelayedSelectInlineLink',
+ expect.any(Function)
+ );
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'cancelPendingSelectInlineLink',
+ expect.any(Function)
+ );
+ });
+
+ describe('dynamic updates', () => {
+ it('should update question when smartSnippetState changes', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet();
+
+ const newQuestion = 'What is the answer to everything?';
+ mockedSmartSnippet.state.question = newQuestion;
+ element.requestUpdate();
+ await element.updateComplete;
+
+ const question = getParts().question!;
+ expect(question.textContent?.trim()).toBe(newQuestion);
+ });
+
+ it('should update answer when smartSnippetState changes', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet();
+
+ const newAnswer = 'New answer content
';
+ mockedSmartSnippet.state.answer = newAnswer;
+ element.requestUpdate();
+ await element.updateComplete;
+
+ expect(getParts().expandableAnswer).toBeInTheDocument();
+ });
+ });
+
+ it('should pass slot attributes to source anchor', async () => {
+ const {element, getParts} = await renderAtomicSmartSnippet();
+
+ const slotElement = document.createElement('a');
+ slotElement.setAttribute('slot', 'source-anchor-attributes');
+ slotElement.setAttribute('target', '_blank');
+ element.appendChild(slotElement);
+
+ expect(getParts().source).toBeInTheDocument();
+ });
+});
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts
new file mode 100644
index 00000000000..8ce64c5e584
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts
@@ -0,0 +1,389 @@
+import {
+ buildSmartSnippet,
+ buildTabManager,
+ type InlineLink,
+ type SmartSnippet,
+ type SmartSnippetState,
+ type TabManager,
+ type TabManagerState,
+} from '@coveo/headless';
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
+import '@/src/components/common/atomic-icon/atomic-icon';
+import '@/src/components/common/atomic-smart-snippet-collapse-wrapper/atomic-smart-snippet-collapse-wrapper';
+import '@/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer';
+import {getAttributesFromLinkSlotContent} from '@/src/components/common/item-link/attributes-slot';
+import {renderSnippetFooter} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-footer';
+import {renderSnippetQuestion} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-question';
+import {renderSnippetTruncatedAnswer} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer';
+import {renderSnippetWrapper} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-wrapper';
+import {renderSmartSnippetFeedbackBanner} from '@/src/components/common/smart-snippets/smart-snippet-feedback-banner';
+import type {Bindings} from '@/src/components/search/atomic-search-interface/interfaces';
+import {arrayConverter} from '@/src/converters/array-converter';
+import {bindStateToController} from '@/src/decorators/bind-state';
+import {bindingGuard} from '@/src/decorators/binding-guard';
+import {bindings} from '@/src/decorators/bindings';
+import {errorGuard} from '@/src/decorators/error-guard';
+import type {InitializableComponent} from '@/src/decorators/types';
+import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles';
+import {shouldDisplayOnCurrentTab} from '@/src/utils/tab-utils';
+import {randomID} from '@/src/utils/utils';
+import styles from './atomic-smart-snippet.tw.css';
+import '@/src/components/common/atomic-smart-snippet-source/atomic-smart-snippet-source';
+import type {AtomicSmartSnippetFeedbackModal} from '@/src/components/search/atomic-smart-snippet-feedback-modal/atomic-smart-snippet-feedback-modal.js';
+
+/**
+ * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query.
+ *
+ * You can style the snippet by inserting a template element as follows:
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @slot source-anchor-attributes - Lets you pass [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes) down to anchor elements, overriding other attributes.
+ * To be used exclusively in anchor elements, such as: ``.
+ *
+ * @part smart-snippet - The wrapper of the entire smart snippet.
+ * @part question - The header displaying the question that is answered by the found document excerpt.
+ * @part answer - The container displaying the full document excerpt.
+ * @part truncated-answer - The container displaying only part of the answer.
+ * @part show-more-button - The show more button.
+ * @part show-less-button - The show less button.
+ * @part body - The body of the smart snippet, containing the truncated answer and the show more or show less button.
+ * @part footer - The footer underneath the answer.
+ * @part source-url - The URL to the document the excerpt is from.
+ * @part source-title - The title of the document the excerpt is from.
+ * @part feedback-banner - The feedback banner underneath the source.
+ * @part feedback-inquiry-and-buttons - A wrapper around the feedback inquiry and the feedback buttons.
+ * @part feedback-inquiry - The message asking the end user to provide feedback on whether the excerpt was useful.
+ * @part feedback-buttons - The wrapper around the buttons after the inquiry.
+ * @part feedback-like-button - The button allowing the end user to signal that the excerpt was useful.
+ * @part feedback-dislike-button - The button allowing the end user to signal that the excerpt wasn't useful.
+ * @part feedback-thank-you-container - The wrapper around the 'thank you' message and feedback button.
+ * @part feedback-thank-you - The message thanking the end user for providing feedback.
+ * @part feedback-explain-why-button - The button a user can press to provide detailed feedback.
+ */
+@customElement('atomic-smart-snippet')
+@bindings()
+@withTailwindStyles
+export class AtomicSmartSnippet
+ extends LitElement
+ implements InitializableComponent
+{
+ static styles = styles;
+
+ @state() public bindings!: Bindings;
+ @state() public error!: Error;
+
+ @bindStateToController('smartSnippet')
+ @state()
+ public smartSnippetState!: SmartSnippetState;
+ public smartSnippet!: SmartSnippet;
+
+ @bindStateToController('tabManager')
+ @state()
+ public tabManagerState!: TabManagerState;
+ public tabManager!: TabManager;
+
+ @state() public feedbackSent = false;
+
+ #id!: string;
+ private modalRef?: AtomicSmartSnippetFeedbackModal;
+
+ /**
+ * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5.
+ */
+ @property({type: Number, reflect: true, attribute: 'heading-level'})
+ headingLevel = 0;
+
+ /**
+ * 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, attribute: 'maximum-height'})
+ maximumHeight = 250;
+
+ /**
+ * When the answer is partly hidden, how much of its height (in pixels) should be visible.
+ */
+ @property({type: Number, reflect: true, attribute: 'collapsed-height'})
+ collapsedHeight = 180;
+
+ /**
+ * Sets the style of the snippet.
+ *
+ * Example:
+ * ```ts
+ * smartSnippet.snippetStyle = `
+ * b {
+ * color: blue;
+ * }
+ * `;
+ * ```
+ */
+ @property({type: String, reflect: true, attribute: 'snippet-style'})
+ snippetStyle?: string;
+
+ /**
+ * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`.
+ *
+ * Set this property as a stringified JSON array, for example:
+ * ```html
+ *
+ * ```
+ * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs.
+ */
+ @property({
+ type: Array,
+ attribute: 'tabs-included',
+ converter: arrayConverter,
+ })
+ tabsIncluded: string[] = [];
+
+ /**
+ * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`.
+ *
+ * Set this property as a stringified JSON array, for example:
+ * ```html
+ *
+ * ```
+ * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs.
+ */
+ @property({
+ type: Array,
+ attribute: 'tabs-excluded',
+ converter: arrayConverter,
+ })
+ tabsExcluded: string[] = [];
+
+ /**
+ * The maximum height (in pixels) for the snippet when using the collapse wrapper.
+ */
+ @property({
+ type: Number,
+ reflect: true,
+ attribute: 'snippet-maximum-height',
+ })
+ snippetMaximumHeight?: number;
+
+ /**
+ * The collapsed height (in pixels) for the snippet when using the collapse wrapper.
+ */
+ @property({
+ type: Number,
+ reflect: true,
+ attribute: 'snippet-collapsed-height',
+ })
+ snippetCollapsedHeight?: number;
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ this.#id ||= randomID();
+ this.addEventListener(
+ 'selectInlineLink',
+ this.onSelectInlineLink as EventListener
+ );
+ this.addEventListener(
+ 'beginDelayedSelectInlineLink',
+ this.onBeginDelayedSelectInlineLink as EventListener
+ );
+ this.addEventListener(
+ 'cancelPendingSelectInlineLink',
+ this.onCancelPendingSelectInlineLink as EventListener
+ );
+ }
+
+ disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.removeEventListener(
+ 'selectInlineLink',
+ this.onSelectInlineLink as EventListener
+ );
+ this.removeEventListener(
+ 'beginDelayedSelectInlineLink',
+ this.onBeginDelayedSelectInlineLink as EventListener
+ );
+ this.removeEventListener(
+ 'cancelPendingSelectInlineLink',
+ this.onCancelPendingSelectInlineLink as EventListener
+ );
+ }
+
+ public initialize() {
+ this.smartSnippet = buildSmartSnippet(this.bindings.engine);
+ this.tabManager = buildTabManager(this.bindings.engine);
+ }
+
+ willUpdate() {
+ if (
+ this.smartSnippetState &&
+ !(this.smartSnippetState.liked || this.smartSnippetState.disliked)
+ ) {
+ this.setFeedbackSent(false);
+ }
+ }
+
+ private onSelectInlineLink(event: CustomEvent) {
+ this.smartSnippet.selectInlineLink(event.detail);
+ }
+
+ private onBeginDelayedSelectInlineLink(event: CustomEvent) {
+ this.smartSnippet.beginDelayedSelectInlineLink(event.detail);
+ }
+
+ private onCancelPendingSelectInlineLink(event: CustomEvent) {
+ this.smartSnippet.cancelPendingSelectInlineLink(event.detail);
+ }
+
+ private setModalRef(ref: HTMLElement) {
+ this.modalRef = ref as AtomicSmartSnippetFeedbackModal;
+ }
+
+ private setFeedbackSent(isSent: boolean) {
+ this.feedbackSent = isSent;
+ }
+
+ private get computedStyle() {
+ const styleTag =
+ this.querySelector('template')?.content.querySelector('style');
+ if (!styleTag) {
+ return this.snippetStyle;
+ }
+ return styleTag.innerHTML;
+ }
+
+ private loadModal() {
+ if (this.modalRef) {
+ return;
+ }
+ const modalRef = document.createElement(
+ 'atomic-smart-snippet-feedback-modal'
+ );
+ modalRef.addEventListener('feedbackSent', () => {
+ this.setFeedbackSent(true);
+ });
+ this.setModalRef(modalRef);
+ this.insertAdjacentElement('beforebegin', modalRef);
+ }
+
+ @bindingGuard()
+ @errorGuard()
+ render() {
+ const shouldDisplay =
+ shouldDisplayOnCurrentTab(
+ this.tabsIncluded,
+ this.tabsExcluded,
+ this.tabManagerState?.activeTab
+ ) && this.smartSnippetState.answerFound;
+
+ this.classList.toggle('atomic-hidden', !shouldDisplay);
+ return html`${when(shouldDisplay, () => this.renderContent())}`;
+ }
+
+ private renderContent() {
+ const source = this.smartSnippetState.source;
+
+ return renderSnippetWrapper({
+ props: {
+ headingLevel: this.headingLevel,
+ i18n: this.bindings.i18n,
+ },
+ })(html`
+
+ ${renderSnippetQuestion({
+ props: {
+ headingLevel: this.headingLevel,
+ question: this.smartSnippetState.question,
+ },
+ })}
+ ${when(
+ this.snippetMaximumHeight !== undefined,
+ () =>
+ renderSnippetTruncatedAnswer({
+ props: {
+ answer: this.smartSnippetState.answer,
+ style: this.computedStyle,
+ },
+ }),
+ () => html`
+ this.smartSnippet.collapse()}
+ @expand=${() => this.smartSnippet.expand()}
+ part="body"
+ .snippetStyle=${this.computedStyle}
+ >
+ `
+ )}
+ ${renderSnippetFooter({
+ props: {i18n: this.bindings.i18n},
+ })(html`
+ ${when(
+ source,
+ () => html`
+
+ `
+ )}
+ ${renderSmartSnippetFeedbackBanner({
+ props: {
+ disliked: this.smartSnippetState.disliked,
+ explainWhyRef: (button?: Element | HTMLButtonElement) => {
+ if (this.modalRef && button) {
+ this.modalRef.source = button as HTMLButtonElement;
+ }
+ },
+ feedbackSent: this.feedbackSent,
+ id: this.#id,
+ i18n: this.bindings.i18n,
+ liked: this.smartSnippetState.liked,
+ onDislike: () => {
+ this.loadModal();
+ this.smartSnippet.dislike();
+ },
+ onLike: () => this.smartSnippet.like(),
+ onPressExplainWhy: () => {
+ if (this.modalRef) {
+ this.modalRef.isOpen = true;
+ }
+ },
+ },
+ })}
+ `)}
+
+ `);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'atomic-smart-snippet': AtomicSmartSnippet;
+ }
+}
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts
new file mode 100644
index 00000000000..5e828fba51e
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts
@@ -0,0 +1,23 @@
+import {css} from 'lit';
+
+export default css`
+ @reference '../../../utils/tailwind.global.tw.css';
+ [part='source-url'] {
+ @apply link-style;
+ @apply set-font-size-base;
+ }
+
+ [part='source-title'] {
+ @apply link-style;
+ @apply set-font-size-xl;
+ @apply mb-6;
+ }
+
+ footer:before {
+ content: ' ';
+ display: block;
+ height: 1px;
+ @apply bg-neutral;
+ margin-bottom: 1.5rem;
+ }
+`;
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts
new file mode 100644
index 00000000000..e5d8502105f
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts
@@ -0,0 +1,9 @@
+import {expect, test} from './fixture';
+
+test.describe('AtomicSmartSnippet', () => {
+ test('should render correctly', async ({smartSnippet}) => {
+ await smartSnippet.load();
+ await smartSnippet.hydrated.waitFor();
+ await expect(smartSnippet.smartSnippet).toBeVisible();
+ });
+});
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/e2e/fixture.ts b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/fixture.ts
new file mode 100644
index 00000000000..140b25e2b80
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/fixture.ts
@@ -0,0 +1,14 @@
+import {test as base} from '@playwright/test';
+import {SmartSnippetPageObject} from './page-object';
+
+type MyFixtures = {
+ smartSnippet: SmartSnippetPageObject;
+};
+
+export const test = base.extend({
+ smartSnippet: async ({page}, use) => {
+ await use(new SmartSnippetPageObject(page));
+ },
+});
+
+export {expect} from '@playwright/test';
diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts
new file mode 100644
index 00000000000..1af70a0b9d2
--- /dev/null
+++ b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts
@@ -0,0 +1,12 @@
+import type {Page} from '@playwright/test';
+import {BasePageObject} from '@/playwright-utils/lit-base-page-object';
+
+export class SmartSnippetPageObject extends BasePageObject {
+ constructor(page: Page) {
+ super(page, 'atomic-smart-snippet');
+ }
+
+ get smartSnippet() {
+ return this.hydrated;
+ }
+}
diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts
index ac5429dd9da..3af544414b0 100644
--- a/packages/atomic/src/components/search/index.ts
+++ b/packages/atomic/src/components/search/index.ts
@@ -67,6 +67,7 @@ export {AtomicSearchInterface} from './atomic-search-interface/atomic-search-int
export {AtomicSearchLayout} from './atomic-search-layout/atomic-search-layout.js';
export {AtomicSegmentedFacet} from './atomic-segmented-facet/atomic-segmented-facet.js';
export {AtomicSegmentedFacetScrollable} from './atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js';
+export {AtomicSmartSnippet} from './atomic-smart-snippet/atomic-smart-snippet.js';
export {AtomicSmartSnippetFeedbackModal} from './atomic-smart-snippet-feedback-modal/atomic-smart-snippet-feedback-modal.js';
export {AtomicSmartSnippetSuggestions} from './atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.js';
export {AtomicSortDropdown} from './atomic-sort-dropdown/atomic-sort-dropdown.js';
diff --git a/packages/atomic/src/components/search/lazy-index.ts b/packages/atomic/src/components/search/lazy-index.ts
index 3ff0cd8e354..64b7172f334 100644
--- a/packages/atomic/src/components/search/lazy-index.ts
+++ b/packages/atomic/src/components/search/lazy-index.ts
@@ -170,6 +170,8 @@ export default {
await import(
'./atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js'
),
+ 'atomic-smart-snippet': async () =>
+ await import('./atomic-smart-snippet/atomic-smart-snippet.js'),
'atomic-smart-snippet-feedback-modal': async () =>
await import(
'./atomic-smart-snippet-feedback-modal/atomic-smart-snippet-feedback-modal.js'
diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx
deleted file mode 100644
index d3d300f6e7c..00000000000
--- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import type {Meta, StoryObj as Story} from '@storybook/web-components-vite';
-import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers';
-import {parameters} from '@/storybook-utils/common/common-meta-parameters';
-import {wrapInSearchInterface} from '@/storybook-utils/search/search-interface-wrapper';
-
-const {events, args, argTypes, template} = getStorybookHelpers(
- 'atomic-smart-snippet',
- {excludeCategories: ['methods']}
-);
-
-const {decorator, play} = wrapInSearchInterface({
- config: {
- search: {
- preprocessSearchResponseMiddleware: (r) => {
- const [result] = r.body.results;
- result.title = 'Manage the Coveo In-Product Experiences (IPX)';
- result.clickUri = 'https://docs.coveo.com/en/3160';
- r.body.questionAnswer = {
- documentId: {
- contentIdKey: 'permanentid',
- contentIdValue: result.raw.permanentid!,
- },
- question: 'Creating an In-Product Experience (IPX)',
- answerSnippet: `
-
- - On the In-Product Experiences page, click Add In-Product Experience.
- - In the Configuration tab, fill the Basic settings section.
- - (Optional) Use the Design and Content access tabs to customize your IPX interface.
- - Click Save.
- - In the Loader snippet panel that appears, you may click Copy to save the loader snippet for your IPX to your clipboard, and then click Save. You can Always retrieve the loader snippet later.
-
-
-
- You're now ready to embed your IPX interface. However, we recommend that you configure query pipelines for your IPX interface before.
-
- `,
- relatedQuestions: [],
- score: 1337,
- };
- return r;
- },
- },
- },
-});
-
-const meta: Meta = {
- component: 'atomic-smart-snippet',
- title: 'Search/SmartSnippet',
- id: 'atomic-smart-snippet',
- render: (args) => template(args),
- decorators: [decorator],
- parameters: {
- ...parameters,
- actions: {
- handles: events,
- },
- },
- args,
- argTypes,
-
- play,
-};
-
-export default meta;
-
-export const Default: Story = {
- name: 'atomic-smart-snippet',
-};
diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss
deleted file mode 100644
index 4a1db7a34e8..00000000000
--- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss
+++ /dev/null
@@ -1 +0,0 @@
-@import '../../../common/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss';
diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx
deleted file mode 100644
index f93e8dbeef9..00000000000
--- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx
+++ /dev/null
@@ -1,302 +0,0 @@
-import {SmartSnippetFeedbackBanner} from '@/src/components/common/smart-snippets/stencil-smart-snippet-feedback-banner';
-import {
- SmartSnippetTruncatedAnswer,
- SmartSnippetWrapper,
- SmartSnippetFooter,
- SmartSnippetQuestion,
-} from '@/src/components/common/smart-snippets/atomic-smart-snippet/stencil-smart-snippet-common';
-import {randomID} from '@/src/utils/utils';
-import {
- buildSmartSnippet,
- buildTabManager,
- InlineLink,
- SmartSnippet,
- SmartSnippetState,
- TabManager,
- TabManagerState,
-} from '@coveo/headless';
-import {Component, Prop, State, Element, Listen, h} from '@stencil/core';
-import {
- InitializableComponent,
- InitializeBindings,
- BindStateToController,
-} from '../../../../utils/initialization-utils';
-import {ArrayProp} from '../../../../utils/props-utils';
-import {shouldDisplayOnCurrentTab} from '../../../../utils/tab-utils';
-import {getAttributesFromLinkSlotContent} from '../../../common/item-link/attributes-slot';
-import {Hidden} from '../../../common/stencil-hidden';
-import {Bindings} from '../../atomic-search-interface/atomic-search-interface';
-import {AtomicSmartSnippetFeedbackModal} from '../../atomic-smart-snippet-feedback-modal/atomic-smart-snippet-feedback-modal';
-
-/**
- * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query.
- *
- * You can style the snippet by inserting a template element as follows:
- * ```html
- *
- *
- *
- *
- *
- * ```
- *
- * @slot source-anchor-attributes - Lets you pass [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes) down to anchor elements, overriding other attributes.
- * To be used exclusively in anchor elements, such as: ``.
- *
- * @part smart-snippet - The wrapper of the entire smart snippet.
- * @part question - The header displaying the question that is answered by the found document excerpt.
- * @part answer - The container displaying the full document excerpt.
- * @part truncated-answer - The container displaying only part of the answer.
- * @part show-more-button - The show more button.
- * @part show-less-button - The show less button.
- * @part body - The body of the smart snippet, containing the truncated answer and the show more or show less button.
- * @part footer - The footer underneath the answer.
- * @part source-url - The URL to the document the excerpt is from.
- * @part source-title - The title of the document the excerpt is from.
- * @part feedback-banner - The feedback banner underneath the source.
- * @part feedback-inquiry-and-buttons - A wrapper around the feedback inquiry and the feedback buttons.
- * @part feedback-inquiry - The message asking the end user to provide feedback on whether the excerpt was useful.
- * @part feedback-buttons - The wrapper around the buttons after the inquiry.
- * @part feedback-like-button - The button allowing the end user to signal that the excerpt was useful.
- * @part feedback-dislike-button - The button allowing the end user to signal that the excerpt wasn't useful.
- * @part feedback-thank-you-container - The wrapper around the 'thank you' message and feedback button.
- * @part feedback-thank-you - The message thanking the end user for providing feedback.
- * @part feedback-explain-why-button - The button a user can press to provide detailed feedback.
- */
-@Component({
- tag: 'atomic-smart-snippet',
- styleUrl: 'atomic-smart-snippet.pcss',
- shadow: true,
-})
-export class AtomicSmartSnippet implements InitializableComponent {
- @InitializeBindings() public bindings!: Bindings;
- public smartSnippet!: SmartSnippet;
- @BindStateToController('smartSnippet')
- @State()
- public smartSnippetState!: SmartSnippetState;
- public tabManager!: TabManager;
- @BindStateToController('tabManager')
- @State()
- public tabManagerState!: TabManagerState;
- public error!: Error;
-
- @Element() private host!: HTMLElement;
- private id!: string;
-
- connectedCallback(): void {
- this.id ||= randomID();
- }
-
- private modalRef?: AtomicSmartSnippetFeedbackModal;
-
- /**
- * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5.
- */
- @Prop({reflect: true}) public headingLevel = 0;
-
- /**
- * 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
- * smartSnippet.snippetStyle = `
- * b {
- * color: blue;
- * }
- * `;
- * ```
- */
- @Prop({reflect: true}) snippetStyle?: string;
-
- /**
- * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`.
- *
- * Set this property as a stringified JSON array, for example:
- * ```html
- *
- * ```
- * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs.
- */
- @ArrayProp()
- @Prop({reflect: true, mutable: true})
- public tabsIncluded: string[] | string = '[]';
-
- /**
- * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`.
- *
- * Set this property as a stringified JSON array, for example:
- * ```html
- *
- * ```
- * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs.
- */
- @ArrayProp()
- @Prop({reflect: true, mutable: true})
- public tabsExcluded: string[] | string = '[]';
-
- @State() feedbackSent = false;
-
- @Prop({reflect: true}) public snippetMaximumHeight?: number;
-
- @Prop({reflect: true}) public snippetCollapsedHeight?: number;
-
- @Listen('selectInlineLink')
- onSelectInlineLink(event: CustomEvent) {
- this.smartSnippet.selectInlineLink(event.detail);
- }
-
- @Listen('beginDelayedSelectInlineLink')
- onBeginDelayedSelectInlineLink(event: CustomEvent) {
- this.smartSnippet.beginDelayedSelectInlineLink(event.detail);
- }
-
- @Listen('cancelPendingSelectInlineLink')
- onCancelPendingSelectInlineLink(event: CustomEvent) {
- this.smartSnippet.cancelPendingSelectInlineLink(event.detail);
- }
-
- public initialize() {
- this.smartSnippet = buildSmartSnippet(this.bindings.engine);
- this.tabManager = buildTabManager(this.bindings.engine);
- }
-
- public componentWillUpdate() {
- if (!(this.smartSnippetState.liked || this.smartSnippetState.disliked)) {
- this.setFeedbackSent(false);
- }
- }
-
- public render() {
- if (
- !shouldDisplayOnCurrentTab(
- [...this.tabsIncluded],
- [...this.tabsExcluded],
- this.tabManagerState?.activeTab
- )
- ) {
- return ;
- }
-
- if (!this.smartSnippetState.answerFound) {
- return ;
- }
-
- const source = this.smartSnippetState.source;
-
- return (
-
-
-
- {this.snippetMaximumHeight !== undefined ? (
-
- ) : (
- this.smartSnippet.collapse()}
- onExpand={() => this.smartSnippet.expand()}
- part="body"
- snippetStyle={this.style}
- >
- )}
-
- {source && (
-
- )}
- {
- if (this.modalRef) {
- this.modalRef.source = button;
- }
- }}
- feedbackSent={this.feedbackSent}
- id={this.id}
- i18n={this.bindings.i18n}
- liked={this.smartSnippetState.liked}
- onDislike={() => {
- this.loadModal();
- this.smartSnippet.dislike();
- }}
- onLike={() => this.smartSnippet.like()}
- onPressExplainWhy={() => (this.modalRef!.isOpen = true)}
- >
-
-
-
- );
- }
-
- private setModalRef(ref: HTMLElement) {
- this.modalRef = ref as AtomicSmartSnippetFeedbackModal;
- }
-
- private setFeedbackSent(isSent: boolean) {
- this.feedbackSent = isSent;
- }
-
- private get style() {
- const styleTag = this.host
- .querySelector('template')
- ?.content.querySelector('style');
- if (!styleTag) {
- return this.snippetStyle;
- }
- return styleTag.innerHTML;
- }
-
- private loadModal() {
- if (this.modalRef) {
- return;
- }
- const modalRef = document.createElement(
- 'atomic-smart-snippet-feedback-modal'
- );
- modalRef.addEventListener('feedbackSent', () => {
- this.setFeedbackSent(true);
- });
- this.setModalRef(modalRef);
- this.host.insertAdjacentElement('beforebegin', modalRef);
- }
-}
diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts
index 52fba702ded..cae3cf10cd1 100644
--- a/packages/atomic/src/utils/custom-element-tags.ts
+++ b/packages/atomic/src/utils/custom-element-tags.ts
@@ -152,6 +152,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([
'atomic-search-layout',
'atomic-segmented-facet',
'atomic-segmented-facet-scrollable',
+ 'atomic-smart-snippet',
'atomic-smart-snippet-answer',
'atomic-smart-snippet-collapse-wrapper',
'atomic-smart-snippet-expandable-answer',
diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts
index 3fff01b02b2..a7eaac0a733 100644
--- a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts
+++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts
@@ -1,31 +1,52 @@
-import type {SmartSnippet} from '@coveo/headless';
+import type {SmartSnippet, SmartSnippetState} from '@coveo/headless';
import {vi} from 'vitest';
+import {genericSubscribe} from '../common';
-export function buildFakeSmartSnippet(
- config: Partial = {}
-): SmartSnippet {
- return {
- state: {
- answerFound: false,
- liked: false,
- disliked: false,
- feedbackModalOpen: false,
- question: '',
- answer: null,
- documentId: {
- contentIdKey: '',
- contentIdValue: '',
- },
- source: null,
- relatedQuestions: [],
- },
- ...config,
- subscribe: config.subscribe ?? vi.fn(),
- openFeedbackModal: config.openFeedbackModal ?? vi.fn(),
- closeFeedbackModal: config.closeFeedbackModal ?? vi.fn(),
- sendFeedback: config.sendFeedback ?? vi.fn(),
- sendDetailedFeedback: config.sendDetailedFeedback ?? vi.fn(),
- like: config.like ?? vi.fn(),
- dislike: config.dislike ?? vi.fn(),
- } as SmartSnippet;
-}
+export const defaultState = {
+ question: '',
+ answer: '',
+ documentId: {
+ contentIdKey: '',
+ contentIdValue: '',
+ },
+ expanded: false,
+ answerFound: false,
+ liked: false,
+ disliked: false,
+ feedbackModalOpen: false,
+ source: undefined,
+} satisfies SmartSnippetState;
+
+export const defaultImplementation = {
+ subscribe: genericSubscribe,
+ state: defaultState,
+ expand: vi.fn() as () => void,
+ collapse: vi.fn() as () => void,
+ like: vi.fn() as () => void,
+ dislike: vi.fn() as () => void,
+ openFeedbackModal: vi.fn() as () => void,
+ closeFeedbackModal: vi.fn() as () => void,
+ sendFeedback: vi.fn() as SmartSnippet['sendFeedback'],
+ sendDetailedFeedback: vi.fn() as SmartSnippet['sendDetailedFeedback'],
+ selectSource: vi.fn() as () => void,
+ beginDelayedSelectSource: vi.fn() as () => void,
+ cancelPendingSelectSource: vi.fn() as () => void,
+ selectInlineLink: vi.fn() as SmartSnippet['selectInlineLink'],
+ beginDelayedSelectInlineLink:
+ vi.fn() as SmartSnippet['beginDelayedSelectInlineLink'],
+ cancelPendingSelectInlineLink:
+ vi.fn() as SmartSnippet['cancelPendingSelectInlineLink'],
+} satisfies SmartSnippet;
+
+export const buildFakeSmartSnippet = ({
+ implementation,
+ state,
+}: Partial<{
+ implementation?: Partial;
+ state?: Partial;
+}> = {}): SmartSnippet =>
+ ({
+ ...defaultImplementation,
+ ...implementation,
+ ...{state: {...defaultState, ...(state || {})}},
+ }) as SmartSnippet;