diff --git a/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts b/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts index 8e207094b63..e8838f529a8 100644 --- a/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts +++ b/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts @@ -74,20 +74,6 @@ describe('Insight Panel test suites', () => { .should('have.attr', 'value', '2'); }); - it('should display a search box', () => { - InsightPanelsSelectors.searchbox() - .should('exist') - .shadow() - .find('textarea[part="textarea"]') - .should('exist') - .should('have.attr', 'placeholder', 'Search...'); - - InsightPanelsSelectors.searchbox() - .shadow() - .find('atomic-icon') - .should('have.attr', 'icon'); - }); - it('should display edit toggle', () => { InsightPanelsSelectors.editToggle() .should('exist') diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index e9baa5db063..73cde2d4bd4 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -364,16 +364,6 @@ export namespace Components { */ "sandbox": string; } - interface AtomicInsightSearchBox { - /** - * Whether to prevent the user from triggering a search from the component. Perfect for use cases where you need to disable the search conditionally, like when the input is empty. - */ - "disableSearch": boolean; - /** - * The number of query suggestions to display when interacting with the search box. - */ - "numberOfSuggestions": number; - } interface AtomicInsightSmartSnippet { /** * When the answer is partly hidden, how much of its height (in pixels) should be visible. @@ -990,12 +980,6 @@ declare global { prototype: HTMLAtomicInsightResultQuickviewActionElement; new (): HTMLAtomicInsightResultQuickviewActionElement; }; - interface HTMLAtomicInsightSearchBoxElement extends Components.AtomicInsightSearchBox, HTMLStencilElement { - } - var HTMLAtomicInsightSearchBoxElement: { - prototype: HTMLAtomicInsightSearchBoxElement; - new (): HTMLAtomicInsightSearchBoxElement; - }; interface HTMLAtomicInsightSmartSnippetElement extends Components.AtomicInsightSmartSnippet, HTMLStencilElement { } var HTMLAtomicInsightSmartSnippetElement: { @@ -1248,7 +1232,6 @@ declare global { "atomic-insight-result-children-template": HTMLAtomicInsightResultChildrenTemplateElement; "atomic-insight-result-list": HTMLAtomicInsightResultListElement; "atomic-insight-result-quickview-action": HTMLAtomicInsightResultQuickviewActionElement; - "atomic-insight-search-box": HTMLAtomicInsightSearchBoxElement; "atomic-insight-smart-snippet": HTMLAtomicInsightSmartSnippetElement; "atomic-insight-smart-snippet-feedback-modal": HTMLAtomicInsightSmartSnippetFeedbackModalElement; "atomic-insight-smart-snippet-suggestions": HTMLAtomicInsightSmartSnippetSuggestionsElement; @@ -1596,16 +1579,6 @@ declare namespace LocalJSX { */ "sandbox"?: string; } - interface AtomicInsightSearchBox { - /** - * Whether to prevent the user from triggering a search from the component. Perfect for use cases where you need to disable the search conditionally, like when the input is empty. - */ - "disableSearch"?: boolean; - /** - * The number of query suggestions to display when interacting with the search box. - */ - "numberOfSuggestions"?: number; - } interface AtomicInsightSmartSnippet { /** * When the answer is partly hidden, how much of its height (in pixels) should be visible. @@ -2045,7 +2018,6 @@ declare namespace LocalJSX { "atomic-insight-result-children-template": AtomicInsightResultChildrenTemplate; "atomic-insight-result-list": AtomicInsightResultList; "atomic-insight-result-quickview-action": AtomicInsightResultQuickviewAction; - "atomic-insight-search-box": AtomicInsightSearchBox; "atomic-insight-smart-snippet": AtomicInsightSmartSnippet; "atomic-insight-smart-snippet-feedback-modal": AtomicInsightSmartSnippetFeedbackModal; "atomic-insight-smart-snippet-suggestions": AtomicInsightSmartSnippetSuggestions; @@ -2095,7 +2067,6 @@ declare module "@stencil/core" { "atomic-insight-result-children-template": LocalJSX.AtomicInsightResultChildrenTemplate & JSXBase.HTMLAttributes; "atomic-insight-result-list": LocalJSX.AtomicInsightResultList & JSXBase.HTMLAttributes; "atomic-insight-result-quickview-action": LocalJSX.AtomicInsightResultQuickviewAction & JSXBase.HTMLAttributes; - "atomic-insight-search-box": LocalJSX.AtomicInsightSearchBox & JSXBase.HTMLAttributes; "atomic-insight-smart-snippet": LocalJSX.AtomicInsightSmartSnippet & JSXBase.HTMLAttributes; "atomic-insight-smart-snippet-feedback-modal": LocalJSX.AtomicInsightSmartSnippetFeedbackModal & JSXBase.HTMLAttributes; "atomic-insight-smart-snippet-suggestions": LocalJSX.AtomicInsightSmartSnippetSuggestions & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/common/suggestions/query-suggestions.spec.ts b/packages/atomic/src/components/common/suggestions/query-suggestions.spec.ts index 64b884bc672..dc110b3a23b 100644 --- a/packages/atomic/src/components/common/suggestions/query-suggestions.spec.ts +++ b/packages/atomic/src/components/common/suggestions/query-suggestions.spec.ts @@ -94,6 +94,29 @@ describe('#renderQuerySuggestion', () => { expect(icon).not.toBeInTheDocument(); }); + it('should render the icon when alwaysShowIcon is true even if hasMultipleKindOfSuggestions is false', async () => { + const suggestion = await renderSuggestion({ + hasMultipleKindOfSuggestions: false, + alwaysShowIcon: true, + }); + + const icon = suggestion.querySelector('atomic-icon'); + + expect(icon).toBeInTheDocument(); + expect(icon).toHaveAttribute('part', 'query-suggestion-icon'); + }); + + it('should not render the icon when alwaysShowIcon is false and hasMultipleKindOfSuggestions is false', async () => { + const suggestion = await renderSuggestion({ + hasMultipleKindOfSuggestions: false, + alwaysShowIcon: false, + }); + + const icon = suggestion.querySelector('atomic-icon'); + + expect(icon).not.toBeInTheDocument(); + }); + it('should render the highlighted value if hasQuery is true', async () => { const suggestion = await renderSuggestion({hasQuery: true}); diff --git a/packages/atomic/src/components/common/suggestions/query-suggestions.ts b/packages/atomic/src/components/common/suggestions/query-suggestions.ts index 532fbf71a02..619addb6c76 100644 --- a/packages/atomic/src/components/common/suggestions/query-suggestions.ts +++ b/packages/atomic/src/components/common/suggestions/query-suggestions.ts @@ -28,6 +28,11 @@ export interface RenderQuerySuggestionOptions { hasQuery: boolean; suggestion: Suggestion; hasMultipleKindOfSuggestions: boolean; + /** + * When true, the icon will always be displayed regardless of hasMultipleKindOfSuggestions. + * Used by the insight search box where icons should always be visible. + */ + alwaysShowIcon?: boolean; } export const renderQuerySuggestion = ({ @@ -35,10 +40,12 @@ export const renderQuerySuggestion = ({ hasQuery, suggestion, hasMultipleKindOfSuggestions, + alwaysShowIcon = false, }: RenderQuerySuggestionOptions): HTMLElement => { + const shouldShowIcon = alwaysShowIcon || hasMultipleKindOfSuggestions; const template = html`
${ - hasMultipleKindOfSuggestions + shouldShowIcon ? html` + + +This component is used within the Insight interface to allow users to enter and submit search queries. +It provides query suggestions as the user types. + +```html + + + + + + + +``` + diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.new.stories.tsx b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.new.stories.tsx new file mode 100644 index 00000000000..1e28b09fdb9 --- /dev/null +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.new.stories.tsx @@ -0,0 +1,53 @@ +import type { + Decorator, + Meta, + StoryObj as Story, +} from '@storybook/web-components-vite'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; +import {html} from 'lit'; +import {MockInsightApi} from '@/storybook-utils/api/insight/mock'; +import {parameters} from '@/storybook-utils/common/common-meta-parameters'; +import {wrapInInsightInterface} from '@/storybook-utils/insight/insight-interface-wrapper'; + +const {events, args, argTypes, template} = getStorybookHelpers( + 'atomic-insight-search-box', + {excludeCategories: ['methods']} +); +const {decorator, play} = wrapInInsightInterface({}, true); + +const mockInsightApi = new MockInsightApi(); + +const normalWidthDecorator: Decorator = (story) => + html`
${story()}
`; + +const meta: Meta = { + component: 'atomic-insight-search-box', + title: 'Insight/Search Box', + id: 'atomic-insight-search-box', + render: (args) => template(args), + decorators: [normalWidthDecorator, decorator], + parameters: { + ...parameters, + actions: { + handles: events, + }, + msw: { + handlers: [...mockInsightApi.handlers], + }, + }, + args, + argTypes, + + play, +}; + +export default meta; + +export const Default: Story = {}; + +export const WithDisabledSearch: Story = { + name: 'With disabled search', + args: { + 'disable-search': true, + }, +}; diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.spec.ts b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.spec.ts new file mode 100644 index 00000000000..ec6caa6f1d3 --- /dev/null +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.spec.ts @@ -0,0 +1,243 @@ +import { + buildSearchBox as buildInsightSearchBox, + type SearchBox as InsightSearchBox, + type SearchBoxState as InsightSearchBoxState, + loadInsightSearchActions, +} from '@coveo/headless/insight'; +import {html} from 'lit'; +import { + beforeEach, + describe, + expect, + it, + type Mock, + type MockInstance, + vi, +} from 'vitest'; +import {userEvent} from 'vitest/browser'; +import {isMacOS} from '@/src/utils/device-utils'; +import {renderInAtomicInsightInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/insight/atomic-insight-interface-fixture'; +import {buildFakeInsightEngine} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/engine'; +import {buildFakeInsightSearchBox} from '@/vitest-utils/testing-helpers/fixtures/headless/insight/search-box-controller'; +import type {AtomicInsightSearchBox} from './atomic-insight-search-box'; +import './atomic-insight-search-box'; + +vi.mock('@coveo/headless/insight', {spy: true}); +vi.mock('@/src/utils/device-utils', {spy: true}); + +describe('atomic-insight-search-box', () => { + const mockedEngine = buildFakeInsightEngine(); + let mockedSearchBox: InsightSearchBox; + let fetchQuerySuggestionsSpy: MockInstance; + let registerQuerySuggestSpy: MockInstance; + let dispatchSpy: MockInstance; + let submitMock: Mock<() => void>; + let clearMock: Mock<() => void>; + + beforeEach(() => { + fetchQuerySuggestionsSpy = vi.fn(() => ({type: 'fetchQuerySuggestions'})); + registerQuerySuggestSpy = vi.fn(() => ({type: 'registerQuerySuggest'})); + dispatchSpy = vi.fn(); + submitMock = vi.fn(); + clearMock = vi.fn(); + + vi.mocked(loadInsightSearchActions).mockReturnValue({ + fetchQuerySuggestions: fetchQuerySuggestionsSpy, + registerQuerySuggest: registerQuerySuggestSpy, + } as unknown as ReturnType); + + mockedEngine.dispatch = + dispatchSpy as unknown as typeof mockedEngine.dispatch; + }); + + const renderComponent = async ({ + searchBoxState = {}, + searchBoxValue = '', + }: { + searchBoxState?: Partial; + searchBoxValue?: string; + } = {}) => { + mockedSearchBox = buildFakeInsightSearchBox({ + state: { + value: searchBoxValue, + suggestions: [], + isLoading: false, + isLoadingSuggestions: false, + ...searchBoxState, + }, + implementation: { + submit: submitMock, + clear: clearMock, + }, + }); + + vi.mocked(buildInsightSearchBox).mockReturnValue(mockedSearchBox); + + const {element} = + await renderInAtomicInsightInterface({ + template: html``, + selector: 'atomic-insight-search-box', + bindings: (bindings) => { + bindings.engine = mockedEngine; + return bindings; + }, + }); + + return { + element, + wrapper: element.shadowRoot!.querySelector('[part="wrapper"]')!, + textArea: element.shadowRoot!.querySelector( + '[part="textarea"]' + )! as HTMLTextAreaElement, + submitIcon: element.shadowRoot!.querySelector('[part="submit-icon"]'), + clearButton: () => + element.shadowRoot!.querySelector('button[part="clear-button"]'), + suggestionsWrapper: () => + element.shadowRoot!.querySelector('[part="suggestions-wrapper"]'), + }; + }; + + it('should build the insight search box controller with the engine', async () => { + await renderComponent(); + expect(buildInsightSearchBox).toHaveBeenCalledWith( + mockedEngine, + expect.objectContaining({ + options: expect.objectContaining({ + numberOfSuggestions: 0, + }), + }) + ); + }); + + it('should register query suggest with the default number of suggestions', async () => { + await renderComponent(); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({type: 'registerQuerySuggest'}) + ); + expect(registerQuerySuggestSpy).toHaveBeenCalledWith( + expect.objectContaining({count: 5}) + ); + }); + + it('should set searchBox property to the controller', async () => { + const {element} = await renderComponent(); + expect(element.searchBox).toBe(mockedSearchBox); + }); + + it('should render the wrapper part', async () => { + const {wrapper} = await renderComponent(); + expect(wrapper).toBeInTheDocument(); + }); + + it('should render the submit icon', async () => { + const {submitIcon} = await renderComponent(); + expect(submitIcon).toBeInTheDocument(); + }); + + it('should render the textarea', async () => { + const {textArea} = await renderComponent(); + expect(textArea).toBeInTheDocument(); + }); + + it('should set the textarea value from state', async () => { + const {textArea} = await renderComponent({ + searchBoxValue: 'test query', + }); + expect(textArea.value).toBe('test query'); + }); + + it('should not render suggestions when there are no suggestions', async () => { + const {suggestionsWrapper} = await renderComponent({ + searchBoxState: {suggestions: []}, + }); + expect(suggestionsWrapper()).toBeNull(); + }); + + describe('when typing in the search box', () => { + it('should update the search box value', async () => { + const {textArea} = await renderComponent(); + await userEvent.type(textArea, 'new query'); + expect(mockedSearchBox.updateText).toHaveBeenCalledWith('new query'); + }); + + it('should trigger query suggestions', async () => { + const {textArea} = await renderComponent(); + await userEvent.type(textArea, 'test'); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({type: 'fetchQuerySuggestions'}) + ); + }); + }); + + describe('when submitting the search', () => { + it('should call submit on the controller when Enter is pressed', async () => { + const {textArea} = await renderComponent(); + await userEvent.type(textArea, '{enter}'); + expect(submitMock).toHaveBeenCalled(); + }); + + it('should not submit when disableSearch is true', async () => { + const {element, textArea} = await renderComponent(); + element.disableSearch = true; + await element.updateComplete; + submitMock.mockClear(); + await userEvent.type(textArea, '{enter}'); + expect(submitMock).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it('should clear suggestions when Escape is pressed', async () => { + const {textArea} = await renderComponent(); + await userEvent.click(textArea); + await userEvent.type(textArea, '{escape}'); + expect(clearMock).not.toHaveBeenCalled(); + }); + + it('should clear suggestions when Tab is pressed', async () => { + const {textArea} = await renderComponent(); + await userEvent.click(textArea); + await userEvent.type(textArea, '{tab}'); + }); + }); + + describe('clear button', () => { + it('should call clear on the controller when clicked', async () => { + const {clearButton} = await renderComponent({ + searchBoxValue: 'test', + }); + const btn = clearButton(); + if (btn) { + await userEvent.click(btn); + expect(clearMock).toHaveBeenCalled(); + } + }); + }); + + it('should have aria-label for the textarea', async () => { + const {textArea} = await renderComponent(); + expect(textArea).toHaveAttribute('aria-label'); + }); + + it('should have the "search-box-with-suggestions-macos" as the aria-label when the device uses macOS', async () => { + vi.mocked(isMacOS).mockReturnValue(true); + + const {textArea} = await renderComponent(); + + expect(textArea).toHaveAttribute( + 'aria-label', + 'Search field with suggestions. Suggestions may be available under this field. To send, press Enter.' + ); + }); + + it('should have the "search-box-with-suggestions" as the aria-label when the device does not use macOS', async () => { + vi.mocked(isMacOS).mockReturnValue(false); + + const {textArea} = await renderComponent(); + + expect(textArea).toHaveAttribute( + 'aria-label', + 'Search field with suggestions. To begin navigating suggestions, while focused, press Down Arrow. To send, press Enter.' + ); + }); +}); diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.ts b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.ts new file mode 100644 index 00000000000..0551ed0d28f --- /dev/null +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.ts @@ -0,0 +1,421 @@ +import { + buildSearchBox as buildInsightSearchBox, + type SearchBox as InsightSearchBox, + type SearchBoxState as InsightSearchBoxState, + type Suggestion as InsightSuggestion, + loadInsightSearchActions, +} from '@coveo/headless/insight'; +import {html, LitElement, nothing, type TemplateResult} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import { + createRef, + type Ref, + type RefOrCallback, + ref, +} from 'lit/directives/ref.js'; +import {renderSearchBoxWrapper} from '@/src/components/common/search-box/search-box-wrapper'; +import {renderSearchBoxTextArea} from '@/src/components/common/search-box/search-text-area'; +import { + getPartialSearchBoxSuggestionElement, + renderQuerySuggestion, +} from '@/src/components/common/suggestions/query-suggestions'; +import {SuggestionManager} from '@/src/components/common/suggestions/suggestion-manager'; +import type {SearchBoxSuggestionElement} from '@/src/components/common/suggestions/suggestions-types'; +import {elementHasQuery} from '@/src/components/common/suggestions/suggestions-utils'; +import type {InsightBindings} from '@/src/components/insight/atomic-insight-interface/atomic-insight-interface'; +import {bindStateToController} from '@/src/decorators/bind-state'; +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 {AriaLiveRegionController} from '@/src/utils/accessibility-utils'; +import {hasKeyboard, isMacOS} from '@/src/utils/device-utils'; +import {isFocusingOut, randomID} from '@/src/utils/utils'; +import SearchSlimIcon from '../../../images/search-slim.svg'; +import '@/src/components/common/atomic-icon/atomic-icon'; +import {bindingGuard} from '@/src/decorators/binding-guard'; +import styles from './atomic-insight-search-box.tw.css'; + +/** + * The `atomic-insight-search-box` component allows users to enter and submit search queries within the Insight interface. + * It provides query suggestions as the user types. + * + * @part wrapper - The wrapper around the entire search box. + * @part submit-icon - The search icon displayed in the search box. + * @part textarea - The text area where users enter their search queries. + * @part textarea-expander - The container that allows the textarea to expand. + * @part clear-button-wrapper - The wrapper for the clear button. + * @part clear-icon - The icon in the clear button. + * @part suggestions-wrapper - The wrapper around the suggestions panel. + * @part suggestions - The container for the suggestions. + */ +@customElement('atomic-insight-search-box') +@bindings() +@withTailwindStyles +export class AtomicInsightSearchBox + extends LitElement + implements InitializableComponent +{ + static styles = styles; + + @state() + public bindings!: InsightBindings; + + @state() + public error!: Error; + + public searchBox!: InsightSearchBox; + + @bindStateToController('searchBox') + @state() + private searchBoxState!: InsightSearchBoxState; + + @state() + private isExpanded = false; + + /** + * Whether to prevent the user from triggering a search from the component. + * Perfect for use cases where you need to disable the search conditionally, like when the input is empty. + */ + @property({type: Boolean, reflect: true, attribute: 'disable-search'}) + public disableSearch = false; + + /** + * The number of query suggestions to display when interacting with the search box. + */ + @property({type: Number, reflect: true, attribute: 'number-of-suggestions'}) + public numberOfSuggestions = 5; + + private searchBoxId!: string; + private textAreaRef: Ref = createRef(); + private suggestionManager!: SuggestionManager; + private searchBoxAriaMessage = new AriaLiveRegionController( + this, + 'search-box' + ); + private suggestionsAriaMessage = new AriaLiveRegionController( + this, + 'search-suggestions', + true + ); + + public initialize() { + this.searchBoxId = randomID('atomic-search-box-'); + + const searchBoxOptions = { + id: this.searchBoxId, + numberOfSuggestions: 0, + highlightOptions: { + notMatchDelimiters: { + open: '', + close: '', + }, + correctionDelimiters: { + open: '', + close: '', + }, + }, + }; + + const {fetchQuerySuggestions, registerQuerySuggest} = + loadInsightSearchActions(this.bindings.engine); + + this.searchBox = buildInsightSearchBox(this.bindings.engine, { + options: searchBoxOptions, + }); + + this.bindings.engine.dispatch( + registerQuerySuggest({ + id: this.searchBoxId, + count: this.numberOfSuggestions, + }) + ); + + this.suggestionManager = new SuggestionManager({ + getNumberOfSuggestionsToDisplay: () => this.numberOfSuggestions, + updateQuery: (query) => this.searchBox.updateText(query), + getSearchBoxValue: () => this.searchBoxState.value, + getSuggestionTimeout: () => 500, + getSuggestionDelay: () => 0, + getHost: () => this, + getLogger: () => this.bindings.engine.logger, + }); + + this.suggestionManager.registerSuggestions({ + position: 0, + renderItems: () => + this.searchBox.state.suggestions.map((suggestion) => + this.renderSuggestionItem(suggestion) + ), + onInput: () => + this.bindings.engine.dispatch( + fetchQuerySuggestions({id: this.searchBoxId}) + ), + panel: 'left', + }); + } + + private onSubmit() { + if (this.suggestionManager.activeDescendantElement) { + this.suggestionManager.clickOnActiveElement(); + return; + } + + this.searchBox.submit(); + this.suggestionManager.clearSuggestions(); + } + + private async onKeyDown(e: KeyboardEvent) { + if (this.disableSearch) { + return; + } + + switch (e.key) { + case 'Enter': + this.onSubmit(); + break; + case 'Escape': + this.suggestionManager.clearSuggestions(); + break; + case 'ArrowDown': + e.preventDefault(); + await this.suggestionManager.focusNextValue(); + this.announceNewActiveSuggestionToScreenReader(); + break; + case 'ArrowUp': + e.preventDefault(); + await this.suggestionManager.focusPreviousValue(); + this.announceNewActiveSuggestionToScreenReader(); + break; + case 'Tab': + this.suggestionManager.clearSuggestions(); + break; + } + } + + private triggerTextAreaChange(value: string) { + if (!this.textAreaRef.value) { + return; + } + this.textAreaRef.value.value = value; + this.textAreaRef.value.dispatchEvent(new window.Event('change')); + } + + private renderSuggestion( + item: SearchBoxSuggestionElement, + index: number, + lastIndex: number + ): TemplateResult | typeof nothing { + const id = `${this.searchBoxId}-suggestion-${item.key}`; + + const isSelected = + id === this.suggestionManager.activeDescendant || + this.suggestionManager.suggestedQuery === item.query; + + if (index === lastIndex && item.hideIfLast) { + return nothing; + } + + return html` { + this.suggestionManager.onSuggestionClick(item, e); + }} + @mouseover=${() => { + this.suggestionManager.onSuggestionMouseOver(item, 'left', id); + }} + >`; + } + + private renderSuggestionItem( + suggestion: InsightSuggestion + ): SearchBoxSuggestionElement { + const hasQuery = this.searchBox.state.value !== ''; + const partialItem = getPartialSearchBoxSuggestionElement( + suggestion, + this.bindings.i18n + ); + + return { + ...partialItem, + content: renderQuerySuggestion({ + icon: SearchSlimIcon, + hasQuery, + suggestion, + hasMultipleKindOfSuggestions: false, + alwaysShowIcon: this.searchBoxState.suggestions.length > 1, + }), + onSelect: () => { + this.searchBox.selectSuggestion(suggestion.rawValue); + }, + }; + } + + private renderPanel( + elements: SearchBoxSuggestionElement[], + setRef: (el: HTMLElement | undefined) => void, + getRef: () => HTMLElement | undefined + ): TemplateResult | typeof nothing { + if (!elements.length) { + return nothing; + } + + return html`
)} + class="flex grow basis-1/2 flex-col" + @mousedown=${(e: MouseEvent) => { + if (e.target === getRef()) { + e.preventDefault(); + } + }} + > + ${elements.map((suggestion, index) => + this.renderSuggestion(suggestion, index, elements.length - 1) + )} +
`; + } + + private renderSuggestions(): TemplateResult | typeof nothing { + if (!this.suggestionManager.hasSuggestions) { + this.suggestionManager.updateActiveDescendant(); + return nothing; + } + + const isVisible = this.suggestionManager.hasSuggestions && this.isExpanded; + + const classes = { + 'bg-background border-neutral absolute top-full left-0 z-10 flex w-full rounded-md border': true, + hidden: !isVisible, + }; + + return html`
+ ${this.renderPanel( + this.suggestionManager.allSuggestionElements, + (el) => { + this.suggestionManager.leftPanel = el; + }, + () => this.suggestionManager.leftPanel + )} +
`; + } + + private getSearchInputLabel(): string { + if (isMacOS()) { + return this.bindings.i18n.t('search-box-with-suggestions-macos'); + } + if (!hasKeyboard()) { + return this.bindings.i18n.t('search-box-with-suggestions-keyboardless'); + } + return this.bindings.i18n.t('search-box-with-suggestions'); + } + + private async onFocus() { + if (this.isExpanded) { + return; + } + this.isExpanded = true; + await this.suggestionManager.triggerSuggestions(); + this.announceNewSuggestionsToScreenReader(); + } + + private async onInput(value: string) { + this.searchBox.updateText(value); + this.isExpanded = true; + await this.suggestionManager.triggerSuggestions(); + this.announceNewSuggestionsToScreenReader(); + } + + private announceNewActiveSuggestionToScreenReader() { + const ariaLabel = this.suggestionManager.activeDescendantElement?.ariaLabel; + if (isMacOS() && ariaLabel) { + this.suggestionsAriaMessage.message = ariaLabel; + } + } + + private announceNewSuggestionsToScreenReader() { + const numberOfSuggestionsToAnnounce = + this.suggestionManager.allSuggestionElements.filter( + elementHasQuery + ).length; + this.searchBoxAriaMessage.message = numberOfSuggestionsToAnnounce + ? this.bindings.i18n.t( + this.searchBoxState.value + ? 'query-suggestions-available' + : 'query-suggestions-available-no-query', + { + count: numberOfSuggestionsToAnnounce, + query: this.searchBoxState.value, + } + ) + : this.bindings.i18n.t('query-suggestions-unavailable'); + } + + @errorGuard() + @bindingGuard() + render() { + return html`${renderSearchBoxWrapper({ + props: { + disabled: this.disableSearch, + onFocusout: (event) => { + if (!isFocusingOut(event)) { + return; + } + this.suggestionManager.clearSuggestions(); + this.isExpanded = false; + }, + }, + })(html` + + ${renderSearchBoxTextArea({ + props: { + textAreaRef: this.textAreaRef, + loading: this.searchBoxState.isLoading, + i18n: this.bindings.i18n, + value: this.searchBoxState.value, + ariaLabel: this.getSearchInputLabel(), + title: this.bindings.i18n.t('search'), + onClear: () => { + this.searchBox.clear(); + this.triggerTextAreaChange(''); + }, + popup: { + id: `${this.searchBoxId}-popup`, + activeDescendant: this.suggestionManager.activeDescendant || '', + expanded: this.isExpanded && this.suggestionManager.hasSuggestions, + hasSuggestions: this.suggestionManager.hasSuggestions, + }, + onInput: (e) => this.onInput((e.target as HTMLInputElement).value), + onFocus: () => this.onFocus(), + onKeyDown: (e) => this.onKeyDown(e), + }, + })} + ${this.renderSuggestions()} + `)}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-insight-search-box': AtomicInsightSearchBox; + } +} diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.tsx b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.tsx deleted file mode 100644 index 217fb044071..00000000000 --- a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import {loadInsightSearchActions} from '@coveo/headless/insight'; -import { - buildSearchBox as buildInsightSearchBox, - SearchBox as InsightSearchBox, - SearchBoxState as InsightSearchBoxState, - Suggestion as InsightSuggestion, -} from '@coveo/headless/insight'; -import {Component, Element, h, Prop, State} from '@stencil/core'; -import SearchSlimIcon from '../../../images/search-slim.svg'; -import {hasKeyboard, isMacOS} from '../../../utils/device-utils'; -import { - BindStateToController, - InitializeBindings, -} from '../../../utils/initialization-utils'; -import {AriaLiveRegion} from '../../../utils/stencil-accessibility-utils'; -import {isFocusingOut, randomID} from '../../../utils/utils'; -import {SearchBoxWrapper} from '../../common/search-box/stencil-search-box-wrapper'; -import {SearchTextArea} from '../../common/search-box/stencil-search-text-area'; -import { - getPartialSearchBoxSuggestionElement, - QuerySuggestionContainer, - QuerySuggestionIcon, - QuerySuggestionText, -} from '../../common/suggestions/stencil-query-suggestions'; -import {SuggestionManager} from '../../common/suggestions/stencil-suggestion-manager'; -import { - elementHasQuery, -} from '../../common/suggestions/suggestions-utils'; -import { - SearchBoxSuggestionElement, -} from '../../common/suggestions/suggestions-types'; -import {InsightBindings} from '../atomic-insight-interface/atomic-insight-interface'; - -/** - * @internal - */ -@Component({ - tag: 'atomic-insight-search-box', - styleUrl: 'atomic-insight-search-box.pcss', - shadow: true, -}) -export class AtomicInsightSearchBox { - @InitializeBindings() public bindings!: InsightBindings; - - private searchBox!: InsightSearchBox; - private id!: string; - private textAreaRef!: HTMLTextAreaElement; - private suggestionManager!: SuggestionManager; - - @Element() private host!: HTMLElement; - - @BindStateToController('searchBox') - @State() - private searchBoxState!: InsightSearchBoxState; - @State() public error!: Error; - @State() private isExpanded = false; - - /** - * Whether to prevent the user from triggering a search from the component. - * Perfect for use cases where you need to disable the search conditionally, like when the input is empty. - */ - @Prop({reflect: true}) public disableSearch = false; - /** - * The number of query suggestions to display when interacting with the search box. - */ - @Prop({reflect: true}) public numberOfSuggestions = 5; - - @AriaLiveRegion('search-box') - protected searchBoxAriaMessage!: string; - - @AriaLiveRegion('search-suggestions', true) - protected suggestionsAriaMessage!: string; - - public initialize() { - this.id = randomID('atomic-search-box-'); - - const searchBoxOptions = { - id: this.id, - numberOfSuggestions: 0, - highlightOptions: { - notMatchDelimiters: { - open: '', - close: '', - }, - correctionDelimiters: { - open: '', - close: '', - }, - }, - }; - - const {fetchQuerySuggestions, registerQuerySuggest} = - loadInsightSearchActions(this.bindings.engine); - - this.searchBox = buildInsightSearchBox(this.bindings.engine, { - options: searchBoxOptions, - }); - - this.bindings.engine.dispatch( - registerQuerySuggest({id: this.id, count: this.numberOfSuggestions}) - ); - - this.suggestionManager = new SuggestionManager({ - getNumberOfSuggestionsToDisplay: () => this.numberOfSuggestions, - updateQuery: (query) => this.searchBox.updateText(query), - getSearchBoxValue: () => this.searchBoxState.value, - getSuggestionTimeout: () => 500, - getSuggestionDelay: () => 0, - getHost: () => this.host, - getLogger: () => this.bindings.engine.logger, - }); - - this.suggestionManager.registerSuggestions({ - position: 0, - renderItems: () => - this.searchBox.state.suggestions.map((suggestion) => - this.renderSuggestionItem(suggestion) - ), - onInput: () => this.bindings.engine.dispatch(fetchQuerySuggestions({id: this.id})), - panel: 'left', - }); - } - - private onSubmit() { - if (this.suggestionManager.activeDescendantElement) { - this.suggestionManager.clickOnActiveElement(); - return; - } - - this.searchBox.submit(); - this.suggestionManager.clearSuggestions(); - } - - private async onKeyDown(e: KeyboardEvent) { - if (this.disableSearch) { - return; - } - - switch (e.key) { - case 'Enter': - this.onSubmit(); - break; - case 'Escape': - this.suggestionManager.clearSuggestions(); - break; - case 'ArrowDown': - e.preventDefault(); - await this.suggestionManager.focusNextValue(); - this.announceNewActiveSuggestionToScreenReader(); - break; - case 'ArrowUp': - e.preventDefault(); - await this.suggestionManager.focusPreviousValue(); - this.announceNewActiveSuggestionToScreenReader(); - break; - case 'Tab': - this.suggestionManager.clearSuggestions(); - break; - } - } - - private triggerTextAreaChange(value: string) { - this.textAreaRef.value = value; - this.textAreaRef.dispatchEvent(new window.Event('change')); - } - - private renderSuggestion( - item: SearchBoxSuggestionElement, - index: number, - lastIndex: number - ) { - const id = `${this.id}-suggestion-${item.key}`; - - const isSelected = - id === this.suggestionManager.activeDescendant || - this.suggestionManager.suggestedQuery === item.query; - - if (index === lastIndex && item.hideIfLast) { - return null; - } - - return ( - { - this.suggestionManager.onSuggestionClick(item, e); - }} - onMouseOver={() => { - this.suggestionManager.onSuggestionMouseOver(item, 'left', id); - }} - > - ); - } - - private renderSuggestionItem( - suggestion: InsightSuggestion - ): SearchBoxSuggestionElement { - const hasQuery = this.searchBox.state.value !== ''; - const partialItem = getPartialSearchBoxSuggestionElement( - suggestion, - this.bindings.i18n - ); - - return { - ...partialItem, - content: ( - - 1} - /> - - - - ), - onSelect: () => { - this.searchBox.selectSuggestion(suggestion.rawValue); - }, - }; - } - - private renderPanel( - elements: SearchBoxSuggestionElement[], - setRef: (el: HTMLElement | undefined) => void, - getRef: () => HTMLElement | undefined - ) { - if (!elements.length) { - return null; - } - - return ( -
{ - if (e.target === getRef()) { - e.preventDefault(); - } - }} - > - {elements.map((suggestion, index) => - this.renderSuggestion(suggestion, index, elements.length - 1) - )} -
- ); - } - - private renderSuggestions() { - if (!this.suggestionManager.hasSuggestions) { - this.suggestionManager.updateActiveDescendant(); - return null; - } - - return ( -
- {this.renderPanel( - this.suggestionManager.allSuggestionElements, - (el) => (this.suggestionManager.leftPanel = el), - () => this.suggestionManager.leftPanel - )} -
- ); - } - - private getSearchInputLabel() { - if (isMacOS()) { - return this.bindings.i18n.t('search-box-with-suggestions-macos'); - } - if (!hasKeyboard()) { - return this.bindings.i18n.t('search-box-with-suggestions-keyboardless'); - } - return this.bindings.i18n.t('search-box-with-suggestions'); - } - - private async onFocus() { - if (this.isExpanded) { - return; - } - this.isExpanded = true; - await this.suggestionManager.triggerSuggestions(); - this.announceNewSuggestionsToScreenReader(); - } - - private async onInput(value: string) { - this.searchBox.updateText(value); - this.isExpanded = true; - await this.suggestionManager.triggerSuggestions(); - this.announceNewSuggestionsToScreenReader(); - } - - private announceNewActiveSuggestionToScreenReader() { - const ariaLabel = this.suggestionManager.activeDescendantElement?.ariaLabel; - if (isMacOS() && ariaLabel) { - this.suggestionsAriaMessage = ariaLabel; - } - } - - private announceNewSuggestionsToScreenReader() { - const numberOfSuggestionsToAnnounce = - this.suggestionManager.allSuggestionElements.filter( - elementHasQuery - ).length; - this.searchBoxAriaMessage = numberOfSuggestionsToAnnounce - ? this.bindings.i18n.t( - this.searchBoxState.value - ? 'query-suggestions-available' - : 'query-suggestions-available-no-query', - { - count: numberOfSuggestionsToAnnounce, - query: this.searchBoxState.value, - } - ) - : this.bindings.i18n.t('query-suggestions-unavailable'); - } - - public render() { - return ( - { - if (!isFocusingOut(event)) { - return; - } - this.suggestionManager.clearSuggestions(); - this.isExpanded = false; - }} - > - - el && (this.textAreaRef = el)} - bindings={this.bindings} - value={this.searchBoxState.value} - ariaLabel={this.getSearchInputLabel()} - placeholder={this.bindings.i18n.t('search-ellipsis')} - onFocus={() => this.onFocus()} - onKeyDown={(e) => this.onKeyDown(e)} - onClear={() => { - this.searchBox.clear(); - this.triggerTextAreaChange(''); - }} - onInput={(e) => this.onInput((e.target as HTMLInputElement).value)} - /> - {this.renderSuggestions()} - - ); - } -} diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.pcss b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.tw.css.ts similarity index 77% rename from packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.pcss rename to packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.tw.css.ts index d978611bf96..a840d400100 100644 --- a/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.pcss +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/atomic-insight-search-box.tw.css.ts @@ -1,5 +1,8 @@ -@import '../../../global/global.pcss'; -@import '../../common/search-box/search-box.pcss'; +import {css} from 'lit'; + +const styles = css` + @reference '../../../utils/tailwind.global.tw.css'; + @import '../../common/search-box/search-box.pcss'; [part='wrapper'] { @apply z-10; @@ -46,3 +49,6 @@ @apply p-2 px-4; } } +`; + +export default styles; diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/atomic-insight-search-box.e2e.ts b/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/atomic-insight-search-box.e2e.ts new file mode 100644 index 00000000000..0f27a107753 --- /dev/null +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/atomic-insight-search-box.e2e.ts @@ -0,0 +1,24 @@ +import {expect, test} from './fixture'; + +test.describe('atomic-insight-search-box', () => { + test.beforeEach(async ({searchBox}) => { + await searchBox.load(); + }); + + test('should render the search input', async ({searchBox}) => { + await expect(searchBox.searchInput).toBeVisible(); + }); + + test('should render suggestions when focused', async ({searchBox}) => { + await searchBox.searchInput.click(); + await expect(searchBox.suggestionsWrapper).toBeVisible(); + await expect(searchBox.searchSuggestions().first()).toBeVisible(); + }); + + test('should submit search on Enter', async ({searchBox}) => { + await searchBox.searchInput.click(); + await searchBox.typeInSearchBox('test query'); + await searchBox.submitSearch(); + await expect(searchBox.searchInput).toHaveValue('test query'); + }); +}); diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/fixture.ts b/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/fixture.ts new file mode 100644 index 00000000000..82737f8e999 --- /dev/null +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/fixture.ts @@ -0,0 +1,14 @@ +import {test as base} from '@playwright/test'; +import {InsightSearchBoxPageObject} from './page-object'; + +type AtomicInsightSearchBoxE2EFixtures = { + searchBox: InsightSearchBoxPageObject; +}; + +export const test = base.extend({ + searchBox: async ({page}, use) => { + await use(new InsightSearchBoxPageObject(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/page-object.ts b/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/page-object.ts new file mode 100644 index 00000000000..55a01c6eb7d --- /dev/null +++ b/packages/atomic/src/components/insight/atomic-insight-search-box/e2e/page-object.ts @@ -0,0 +1,48 @@ +import type {Page} from '@playwright/test'; +import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; + +export class InsightSearchBoxPageObject extends BasePageObject { + constructor(page: Page) { + super(page, 'atomic-insight-search-box'); + } + + get component() { + return this.page.locator('atomic-insight-search-box'); + } + + get searchInput() { + return this.component.getByRole('textbox'); + } + + get submitIcon() { + return this.component.locator('[part="submit-icon"]'); + } + + get clearButton() { + return this.component.getByRole('button', {name: /clear/i}); + } + + get suggestionsWrapper() { + return this.component.locator('[part="suggestions-wrapper"]'); + } + + searchSuggestions({index, total}: {index?: number; total?: number} = {}) { + return this.page.getByLabel( + new RegExp( + `suggested query\\.(?: Button\\.)? ${index ?? '\\d+'} of ${total ?? '\\d+'}\\.` + ) + ); + } + + async typeInSearchBox(text: string) { + await this.searchInput.fill(text); + } + + async clearSearchBox() { + await this.clearButton.click(); + } + + async submitSearch() { + await this.searchInput.press('Enter'); + } +} diff --git a/packages/atomic/src/components/insight/index.ts b/packages/atomic/src/components/insight/index.ts index 87759a352da..ff3f6544979 100644 --- a/packages/atomic/src/components/insight/index.ts +++ b/packages/atomic/src/components/insight/index.ts @@ -11,6 +11,7 @@ export {AtomicInsightQueryError} from './atomic-insight-query-error/atomic-insig export {AtomicInsightQuerySummary} from './atomic-insight-query-summary/atomic-insight-query-summary.js'; export {AtomicInsightRefineToggle} from './atomic-insight-refine-toggle/atomic-insight-refine-toggle.js'; export {AtomicInsightResultTemplate} from './atomic-insight-result-template/atomic-insight-result-template.js'; +export {AtomicInsightSearchBox} from './atomic-insight-search-box/atomic-insight-search-box.js'; export {AtomicInsightTab} from './atomic-insight-tab/atomic-insight-tab.js'; export {AtomicInsightTabs} from './atomic-insight-tabs/atomic-insight-tabs.js'; export {AtomicInsightUserActionsToggle} from './atomic-insight-user-actions-toggle/atomic-insight-user-actions-toggle.js'; diff --git a/packages/atomic/src/components/insight/lazy-index.ts b/packages/atomic/src/components/insight/lazy-index.ts index 4464239d8ae..e042712dd84 100644 --- a/packages/atomic/src/components/insight/lazy-index.ts +++ b/packages/atomic/src/components/insight/lazy-index.ts @@ -36,6 +36,8 @@ export default { await import( './atomic-insight-result-template/atomic-insight-result-template.js' ), + 'atomic-insight-search-box': async () => + await import('./atomic-insight-search-box/atomic-insight-search-box.js'), 'atomic-insight-tab': async () => await import('./atomic-insight-tab/atomic-insight-tab.js'), 'atomic-insight-tabs': async () => diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 6c32065a706..a7bb22c8782 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -69,6 +69,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-insight-query-summary', 'atomic-insight-refine-toggle', 'atomic-insight-result-template', + 'atomic-insight-search-box', 'atomic-insight-tab', 'atomic-insight-tabs', 'atomic-insight-user-actions-toggle', diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/search-box-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/search-box-controller.ts new file mode 100644 index 00000000000..c492702c476 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/insight/search-box-controller.ts @@ -0,0 +1,36 @@ +import type { + SearchBox as InsightSearchBox, + SearchBoxState as InsightSearchBoxState, +} from '@coveo/headless/insight'; +import {vi} from 'vitest'; +import {genericSubscribe} from '../common'; + +export const defaultState = { + value: '', + suggestions: [], + isLoading: false, + isLoadingSuggestions: false, +} satisfies InsightSearchBoxState; + +export const defaultImplementation = { + subscribe: genericSubscribe, + state: defaultState, + updateText: vi.fn(), + clear: vi.fn(), + submit: vi.fn(), + selectSuggestion: vi.fn(), + showSuggestions: vi.fn(), +} satisfies InsightSearchBox; + +export const buildFakeInsightSearchBox = ({ + implementation, + state, +}: Partial<{ + implementation?: Partial; + state?: Partial; +}> = {}): InsightSearchBox => + ({ + ...defaultImplementation, + ...implementation, + ...{state: {...defaultState, ...(state || {})}}, + }) as InsightSearchBox;