From 611f2d34718f1802a500db5adb21e09daad3db54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:48:09 +0000 Subject: [PATCH 1/8] Initial plan From 919a44719e29b4ec0a734c70dd6bbbb994ae8ef8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 13:58:18 +0000 Subject: [PATCH 2/8] feat(atomic): migrate atomic-search-box component to Lit Co-authored-by: alexprudhomme <78121423+alexprudhomme@users.noreply.github.com> --- .../atomic-search-box/atomic-search-box.ts | 780 ++++++++++++++++++ .../atomic/src/components/search/index.ts | 1 + .../src/components/search/lazy-index.ts | 2 + 3 files changed, 783 insertions(+) create mode 100644 packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts new file mode 100644 index 00000000000..90be6c66f3a --- /dev/null +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts @@ -0,0 +1,780 @@ +import {isNullOrUndefined} from '@coveo/bueno'; +import { + buildSearchBox, + buildStandaloneSearchBox, + type SearchBox, + type SearchBoxOptions, + type SearchBoxState, + type StandaloneSearchBox, + type StandaloneSearchBoxState, +} from '@coveo/headless'; +import {type CSSResultGroup, css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {createRef, type RefOrCallback, ref} from 'lit/directives/ref.js'; +import type {RedirectionPayload} from '@/src/components/common/search-box/redirection-payload'; +import {renderSearchBoxWrapper} from '@/src/components/common/search-box/search-box-wrapper'; +import {renderSearchBoxTextArea} from '@/src/components/common/search-box/search-text-area'; +import {renderSubmitButton} from '@/src/components/common/search-box/submit-button'; +import {SuggestionManager} from '@/src/components/common/suggestions/suggestion-manager'; +import type { + SearchBoxSuggestionElement, + SearchBoxSuggestionsBindings, + SearchBoxSuggestionsEvent, +} from '@/src/components/common/suggestions/suggestions-types'; +import {elementHasQuery} from '@/src/components/common/suggestions/suggestions-utils'; +import type {Bindings} from '@/src/components/search/atomic-search-interface/atomic-search-interface'; +import {booleanConverter} from '@/src/converters/boolean-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 {watch} from '@/src/decorators/watch'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js'; +import {AriaLiveRegionController} from '@/src/utils/accessibility-utils'; +import {hasKeyboard, isMacOS} from '@/src/utils/device-utils'; +import { + SafeStorage, + type StandaloneSearchBoxData, + StorageItems, +} from '@/src/utils/local-storage-utils'; +import {updateBreakpoints} from '@/src/utils/replace-breakpoint-utils'; +import {getDefaultSlotContent} from '@/src/utils/slot-utils'; +import { + isFocusingOut, + once, + randomID, + spreadProperties, +} from '@/src/utils/utils'; +import '@/src/components/search/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions'; +import '@/src/components/search/atomic-search-box-recent-queries/atomic-search-box-recent-queries'; + +/** + * The `atomic-search-box` component creates a search box with built-in support for suggestions. + * + * @slot default - The default slot where you can add child components to the search box. + * + * @part wrapper - The search box wrapper. + * @part input - The search box input. + * @part loading - The search box loading animation. + * @part clear-button - The button to clear the search box of input. + * @part clear-icon - The clear button's icon. + * @part submit-button - The search box submit button. + * @part submit-icon - The search box submit button's icon. + * @part suggestions - A list of suggested query corrections on each panel. + * @part suggestions-left - A list of suggested query corrections on the left panel. + * @part suggestions-right - A list of suggested query corrections on the right panel. + * @part suggestions-wrapper - The wrapper that contains suggestion panels. + * @part suggestions-single-list - The wrapper that contains 1 suggestion list. + * @part suggestions-double-list - The wrapper that contains 2 suggestion lists. + * @part suggestion - A suggested query correction. + * @part active-suggestion - The currently active suggestion. + * @part suggestion-divider - An item in the list that separates groups of suggestions. + * @part suggestion-with-query - An item in the list that will update the search box query. + * + * @part query-suggestion-item - A suggestion from the `atomic-search-box-query-suggestions` component. + * @part query-suggestion-content - The contents of a suggestion from the `atomic-search-box-query-suggestions` component. + * @part query-suggestion-icon - The icon of a suggestion from the `atomic-search-box-query-suggestions` component. + * @part query-suggestion-text - The text of a suggestion from the `atomic-search-box-query-suggestions` component. + * + * @part recent-query-item - A suggestion from the `atomic-search-box-recent-queries` component. + * @part recent-query-content - The contents of a suggestion from the `atomic-search-box-recent-queries` component. + * @part recent-query-icon - The icon of a suggestion from the `atomic-search-box-recent-queries` component. + * @part recent-query-text - The text of a suggestion from the `atomic-search-box-recent-queries` component. + * @part recent-query-text-highlight - The highlighted portion of the text of a suggestion from the `atomic-search-box-recent-queries` component. + * @part recent-query-title-item - The clear button above suggestions from the `atomic-search-box-recent-queries` component. + * @part recent-query-title-content - The contents of the clear button above suggestions from the `atomic-search-box-recent-queries` component. + * @part recent-query-title - The "recent searches" text of the clear button above suggestions from the `atomic-search-box-recent-queries` component. + * @part recent-query-clear - The "clear" text of the clear button above suggestions from the `atomic-search-box-recent-queries` component. + * + * @part instant-results-item - An instant result rendered by an `atomic-search-box-instant-results` component. + * @part instant-results-show-all - The clickable suggestion to show all items for the current instant results search rendered by an `atomic-search-box-instant-results` component. + * @part instant-results-show-all-button - The button inside the clickable suggestion from the `atomic-search-box-instant-results` component. + * + * @event redirect - Event that is emitted when a standalone search box redirection is triggered. By default, the search box will directly change the URL and redirect accordingly, so if you want to handle the redirection differently, use this event. + */ +@customElement('atomic-search-box') +@bindings() +@withTailwindStyles +export class AtomicSearchBox + extends LitElement + implements InitializableComponent +{ + static styles: CSSResultGroup = + css`@import "../atomic-search-box/atomic-search-box.pcss";`; + + @state() bindings!: Bindings; + @state() error!: Error; + @state() isExpanded = false; + @bindStateToController('searchBox') + @state() + private searchBoxState!: SearchBoxState | StandaloneSearchBoxState; + + private textAreaRef = createRef(); + private searchBoxSuggestionEventsQueue: CustomEvent< + SearchBoxSuggestionsEvent + >[] = []; + private suggestionManager!: SuggestionManager< + SearchBox | StandaloneSearchBox + >; + public searchBox!: SearchBox | StandaloneSearchBox; + private id!: string; + + protected searchBoxAriaMessage = new AriaLiveRegionController( + this, + 'search-box' + ); + + protected suggestionsAriaMessage = new AriaLiveRegionController( + this, + 'search-suggestions', + true + ); + + /** + * The amount of queries displayed when the user interacts with the search box. + * By default, a mix of query suggestions and recent queries will be shown. + * You can configure those settings using the following components as children: + * - atomic-search-box-query-suggestions + * - atomic-search-box-recent-queries + */ + @property({type: Number, attribute: 'number-of-queries', reflect: true}) + numberOfQueries = 8; + + /** + * Defining this option makes the search box standalone (see [Use a + * Standalone Search Box](https://docs.coveo.com/en/atomic/latest/usage/ssb/)). + * + * This option defines the default URL the user should be redirected to, when a query is submitted. + * If a query pipeline redirect is triggered, it will redirect to that URL instead + * (see [query pipeline triggers](https://docs.coveo.com/en/1458)). + */ + @property({type: String, attribute: 'redirection-url', reflect: true}) + public redirectionUrl?: string; + + /** + * The timeout for suggestion queries, in milliseconds. + * If a suggestion query times out, the suggestions from that particular query won't be shown. + */ + @property({type: Number, attribute: 'suggestion-timeout'}) + public suggestionTimeout = 400; + + /** + * The delay for suggestion queries on input, in milliseconds. + * + * The suggestion request will be delayed until the end user stops typing for at least the specified amount of time. + * + * This delay is used to avoid sending too many requests to the Coveo Platform when the user is typing, as well as reducing potential input lag on low end devices. + * A higher delay will reduce input lag, at the cost of suggestions freshness. + */ + @property({type: Number, attribute: 'suggestion-delay'}) + public suggestionDelay = 0; + + /** + * Whether to prevent the user from triggering searches and query suggestions from the component. + * Perfect for use cases where you need to disable the search conditionally. + * For the specific case when you need to disable the search based on the length of the query, refer to {@link minimumQueryLength}. + */ + @property({ + type: Boolean, + attribute: 'disable-search', + reflect: true, + converter: booleanConverter, + }) + public disableSearch = false; + + /** + * The minimum query length required to enable search. + * For example, to disable the search for empty queries, set this to `1`. + */ + @property({type: Number, attribute: 'minimum-query-length', reflect: true}) + public minimumQueryLength = 0; + + /** + * Whether to clear all active query filters when the end user submits a new query from the search box. + * Setting this option to "false" is not recommended and can lead to an increasing number of queries returning no results. + */ + @property({ + type: Boolean, + attribute: 'clear-filters', + reflect: true, + converter: booleanConverter, + }) + public clearFilters = true; + + /** + * Whether to interpret advanced [Coveo query syntax](https://docs.coveo.com/en/1552/) in the query. + * You should only enable query syntax in the search box if you have good reasons to do so, as it + * requires end users to be familiar with Coveo query syntax, otherwise they will likely be surprised + * by the search box behaviour. + * + * When the `redirection-url` property is set and redirects to a page with more `atomic-search-box` components, all `atomic-search-box` components need to have the same `enable-query-syntax` value. + */ + @property({ + type: Boolean, + attribute: 'enable-query-syntax', + reflect: true, + converter: booleanConverter, + }) + public enableQuerySyntax = false; + + connectedCallback() { + super.connectedCallback(); + + this.addEventListener( + 'atomic/searchBoxSuggestion/register', + (event: Event) => { + const customEvent = event as CustomEvent< + SearchBoxSuggestionsEvent + >; + if (!this.bindings) { + this.searchBoxSuggestionEventsQueue.push(customEvent); + } else { + this.suggestionManager.registerSuggestionsFromEvent( + customEvent, + this.suggestionBindings + ); + } + } + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + willUpdate() { + if (!this.searchBoxState || !this.searchBox) { + return; + } + if ( + !('redirectTo' in this.searchBoxState) || + !('afterRedirection' in this.searchBox) + ) { + return; + } + + const {redirectTo, value, analytics} = this.searchBoxState; + + if (redirectTo === '') { + return; + } + const data: StandaloneSearchBoxData = { + value, + enableQuerySyntax: this.enableQuerySyntax, + analytics, + }; + const storage = new SafeStorage(); + storage.setJSON(StorageItems.STANDALONE_SEARCH_BOX_DATA, data); + + this.searchBox.afterRedirection(); + const event = new CustomEvent('redirect', { + detail: {redirectTo, value}, + }); + this.dispatchEvent(event); + if (!event.defaultPrevented) { + window.location.href = redirectTo; + } + } + + public initialize() { + this.id = randomID('atomic-search-box-'); + + this.initializeSearchboxController(); + this.initializeSuggestionManager(); + } + + private initializeSearchboxController() { + this.searchBox = this.redirectionUrl + ? buildStandaloneSearchBox(this.bindings.engine, { + options: { + ...this.searchBoxOptions, + redirectionUrl: this.redirectionUrl, + overwrite: true, + }, + }) + : buildSearchBox(this.bindings.engine, { + options: this.searchBoxOptions, + }); + } + + private initializeSuggestionManager() { + if (this.suggestionManager) { + return; + } + + this.suggestionManager = new SuggestionManager({ + getNumberOfSuggestionsToDisplay: () => this.numberOfQueries, + updateQuery: (query) => this.searchBox.updateText(query), + getSearchBoxValue: () => this.searchBoxState.value, + getSuggestionTimeout: () => this.suggestionTimeout, + getSuggestionDelay: () => this.suggestionDelay, + getHost: () => this, + getLogger: () => this.bindings.engine.logger, + }); + this.suggestionManager.initializeSuggestions(this.suggestionBindings); + } + + @watch('redirectionUrl') + watchRedirectionUrl() { + if (this.isStandaloneSearchBox(this.searchBox) && this.redirectionUrl) { + this.searchBox.updateRedirectUrl(this.redirectionUrl); + } else { + this.registerNewSearchBoxController(); + } + } + + private isStandaloneSearchBox( + searchBox: SearchBox | StandaloneSearchBox + ): searchBox is StandaloneSearchBox { + return 'redirectTo' in searchBox; + } + + private updateBreakpoints = once(() => updateBreakpoints(this)); + + private get isSearchDisabledForEndUser() { + const queryValue = this.searchBoxState?.value; + if (isNullOrUndefined(queryValue)) { + return this.disableSearch; + } + + if (queryValue.trim().length < this.minimumQueryLength) { + return true; + } + + return this.disableSearch; + } + + private get textAreaLabel() { + if (this.isSearchDisabledForEndUser) { + return this.bindings.i18n.t('search-disabled', { + length: this.minimumQueryLength, + }); + } + + 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 get suggestionBindings(): SearchBoxSuggestionsBindings< + SearchBox | StandaloneSearchBox + > { + return spreadProperties( + this.bindings, + this.suggestionManager.partialSuggestionBindings, + this.partialSuggestionBindings + ); + } + + private get partialSuggestionBindings(): Pick< + SearchBoxSuggestionsBindings, + | 'id' + | 'isStandalone' + | 'searchBoxController' + | 'numberOfQueries' + | 'clearFilters' + > { + return Object.defineProperties( + {...this.bindings}, + { + id: { + get: () => this.id, + enumerable: true, + }, + searchBoxController: { + get: () => this.searchBox, + enumerable: true, + }, + isStandalone: { + get: () => !!this.redirectionUrl, + enumerable: true, + }, + numberOfQueries: { + get: () => this.numberOfQueries, + enumerable: true, + }, + clearFilters: { + get: () => this.clearFilters, + enumerable: true, + }, + } + ) as unknown as Pick< + SearchBoxSuggestionsBindings, + | 'id' + | 'isStandalone' + | 'searchBoxController' + | 'numberOfQueries' + | 'clearFilters' + >; + } + + private get searchBoxOptions(): SearchBoxOptions { + return { + id: this.id, + numberOfSuggestions: 0, + highlightOptions: { + notMatchDelimiters: { + open: '', + close: '', + }, + correctionDelimiters: { + open: '', + close: '', + }, + }, + clearFilters: this.clearFilters, + enableQuerySyntax: this.enableQuerySyntax, + }; + } + + private registerNewSearchBoxController() { + this.disconnectedCallback(); + this.initialize(); + } + + private registerSearchboxSuggestionEvents() { + this.searchBoxSuggestionEventsQueue.forEach((evt) => { + this.suggestionManager.registerSuggestionsFromEvent( + evt, + this.suggestionBindings + ); + }); + this.searchBoxSuggestionEventsQueue = []; + } + + private async onInput(value: string) { + this.searchBox.updateText(value); + + if (this.isSearchDisabledForEndUser) { + this.suggestionManager.clearSuggestions(); + return; + } + this.isExpanded = true; + await this.suggestionManager.triggerSuggestions(); + this.announceNewSuggestionsToScreenReader(); + } + + private async onFocus() { + if (this.isExpanded) { + return; + } + if (this.isSearchDisabledForEndUser) { + return; + } + this.isExpanded = true; + await this.suggestionManager.triggerSuggestions(); + this.announceNewSuggestionsToScreenReader(); + } + + private onSubmit() { + this.isExpanded = false; + if (this.suggestionManager.keyboardActiveDescendant) { + this.suggestionManager.onSubmit(); + return; + } + + this.searchBox.submit(); + } + + private async onKeyDown(e: KeyboardEvent) { + if (this.isSearchDisabledForEndUser) { + 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 'ArrowRight': + if ( + this.suggestionManager.hasActiveDescendant || + !this.searchBox.state.value + ) { + e.preventDefault(); + this.suggestionManager.focusPanel('right'); + this.announceNewActiveSuggestionToScreenReader(); + } + break; + case 'ArrowLeft': + if ( + this.suggestionManager.hasActiveDescendant || + !this.searchBox.state.value + ) { + e.preventDefault(); + this.suggestionManager.focusPanel('left'); + this.announceNewActiveSuggestionToScreenReader(); + } + break; + case 'Tab': + this.suggestionManager.clearSuggestions(); + break; + default: + if (this.suggestionManager.keyboardActiveDescendant) { + this.suggestionManager.updateKeyboardActiveDescendant(); + } + break; + } + } + + private triggerTextAreaChange(value: string) { + this.textAreaRef.value!.value = value; + this.textAreaRef.value!.dispatchEvent(new window.Event('change')); + } + + private announceNewActiveSuggestionToScreenReader() { + const ariaLabel = this.suggestionManager.activeDescendantElement?.ariaLabel; + if (isMacOS() && ariaLabel) { + this.suggestionsAriaMessage.message = ariaLabel; + } + } + + private announceNewSuggestionsToScreenReader() { + const elsLength = + this.suggestionManager.allSuggestionElements.filter( + elementHasQuery + ).length; + + this.searchBoxAriaMessage.message = elsLength + ? this.bindings.i18n.t( + this.searchBoxState.value + ? 'query-suggestions-available' + : 'query-suggestions-available-no-query', + { + count: elsLength, + query: this.searchBoxState.value, + } + ) + : this.bindings.i18n.t('query-suggestions-unavailable'); + } + + private announceClearSearchBoxToScreenReader() { + this.searchBoxAriaMessage.message = + this.bindings.i18n.t('search-box-cleared'); + } + + private renderAbsolutePositionSpacer() { + return html``; + } + + private renderTextBox() { + return html`${renderSearchBoxTextArea({ + props: { + textAreaRef: this.textAreaRef, + loading: this.searchBoxState.isLoading, + i18n: this.bindings.i18n, + value: this.searchBoxState.value, + title: this.textAreaLabel, + ariaLabel: this.textAreaLabel, + onFocus: () => this.onFocus(), + onInput: (e: Event) => + this.onInput((e.target as HTMLTextAreaElement).value), + onKeyDown: (e: KeyboardEvent) => this.onKeyDown(e), + onClear: () => { + this.searchBox.clear(); + this.suggestionManager.clearSuggestions(); + this.triggerTextAreaChange(''); + this.announceClearSearchBoxToScreenReader(); + }, + popup: { + id: `${this.id}-popup`, + activeDescendant: this.suggestionManager.activeDescendant, + expanded: this.isExpanded, + hasSuggestions: this.suggestionManager.hasSuggestions, + }, + }, + })}`; + } + + private renderSuggestions() { + const part = `suggestions-wrapper ${ + this.suggestionManager.isDoubleList + ? 'suggestions-double-list' + : 'suggestions-single-list' + }`; + + const isVisible = + this.suggestionManager.hasSuggestions && + this.isExpanded && + !this.isSearchDisabledForEndUser; + + 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( + 'left', + this.suggestionManager.leftSuggestionElements, + (el) => { + this.suggestionManager.leftPanel = el; + }, + () => this.suggestionManager.leftPanel + )} + ${this.renderPanel( + 'right', + this.suggestionManager.rightSuggestionElements, + (el) => { + this.suggestionManager.rightPanel = el; + }, + () => this.suggestionManager.rightPanel + )} +
`; + } + + private renderPanel( + side: 'left' | 'right', + elements: SearchBoxSuggestionElement[], + setRef: (el: HTMLElement | undefined) => void, + getRef: () => HTMLElement | undefined + ) { + if (!elements.length) { + return null; + } + + 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, side) + )} +
`; + } + + private renderSuggestion( + item: SearchBoxSuggestionElement, + index: number, + lastIndex: number, + side: 'left' | 'right' + ) { + const id = `${this.id}-${side}-suggestion-${item.key}`; + + const isSelected = id === this.suggestionManager.activeDescendant; + + if (index === lastIndex && item.hideIfLast) { + return null; + } + return html` + { + await this.suggestionManager.onSuggestionClick(item, e); + if (item.key === 'recent-query-clear') { + return; + } + + this.isExpanded = false; + }} + .onMouseOver=${async () => { + await this.suggestionManager.onSuggestionMouseOver(item, side, id); + }} + > + `; + } + + private renderSlotContent() { + const hasDefaultSlot = getDefaultSlotContent(this).length > 0; + + if (hasDefaultSlot) { + return html``; + } + + return html` + `; + } + + @bindingGuard() + @errorGuard() + render() { + this.updateBreakpoints(); + + if (!this.suggestionManager.suggestions.length) { + this.registerSearchboxSuggestionEvents(); + } + + return html` + ${this.renderAbsolutePositionSpacer()} + ${renderSearchBoxWrapper({ + props: { + disabled: this.isSearchDisabledForEndUser, + onFocusout: (event) => { + if (!isFocusingOut(event)) { + return; + } + this.suggestionManager.clearSuggestions(); + this.isExpanded = false; + }, + }, + })( + html`${this.renderTextBox()} + ${renderSubmitButton({ + props: { + i18n: this.bindings.i18n, + disabled: this.isSearchDisabledForEndUser, + onClick: () => { + this.searchBox.submit(); + this.suggestionManager.clearSuggestions(); + }, + }, + })} + ${this.renderSuggestions()}` + )} + ${this.renderSlotContent()} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-search-box': AtomicSearchBox; + } +} diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts index 2ec0c28e106..2efee9905e5 100644 --- a/packages/atomic/src/components/search/index.ts +++ b/packages/atomic/src/components/search/index.ts @@ -26,6 +26,7 @@ export {AtomicResultSectionVisual} from './atomic-result-section-visual/atomic-r export {AtomicResultTemplate} from './atomic-result-template/atomic-result-template.js'; export {AtomicResultText} from './atomic-result-text/atomic-result-text.js'; export {AtomicResultsPerPage} from './atomic-results-per-page/atomic-results-per-page.js'; +export {AtomicSearchBox} from './atomic-search-box/atomic-search-box.js'; export {AtomicSearchBoxInstantResults} from './atomic-search-box-instant-results/atomic-search-box-instant-results.js'; export {AtomicSearchBoxQuerySuggestions} from './atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.js'; export {AtomicSearchBoxRecentQueries} from './atomic-search-box-recent-queries/atomic-search-box-recent-queries.js'; diff --git a/packages/atomic/src/components/search/lazy-index.ts b/packages/atomic/src/components/search/lazy-index.ts index ef6ec5fcb77..3ede2aa533e 100644 --- a/packages/atomic/src/components/search/lazy-index.ts +++ b/packages/atomic/src/components/search/lazy-index.ts @@ -70,6 +70,8 @@ export default { await import('./atomic-result-text/atomic-result-text.js'), 'atomic-results-per-page': async () => await import('./atomic-results-per-page/atomic-results-per-page.js'), + 'atomic-search-box': async () => + await import('./atomic-search-box/atomic-search-box.js'), 'atomic-search-box-instant-results': async () => await import( './atomic-search-box-instant-results/atomic-search-box-instant-results.js' From 9883a1de0f7b97cacb1301846236dfee4012c8e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:08:11 +0000 Subject: [PATCH 3/8] test(atomic): add comprehensive unit tests for atomic-search-box - Created test fixtures for search box controllers - Added test helper for fake search box suggestions - Comprehensive test coverage for props, controller integration, keyboard navigation, and accessibility Co-authored-by: alexprudhomme <78121423+alexprudhomme@users.noreply.github.com> --- packages/atomic/src/components.d.ts | 116 ----- .../atomic-search-box.spec.ts | 418 +++++++++++++++++- ...e-atomic-search-box-suggestions-fixture.ts | 55 +++ .../headless/search/search-box-controller.ts | 26 ++ .../standalone-search-box-controller.ts | 37 ++ 5 files changed, 515 insertions(+), 137 deletions(-) create mode 100644 packages/atomic/vitest-utils/testing-helpers/fixtures/atomic/search/fake-atomic-search-box-suggestions-fixture.ts create mode 100644 packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts create mode 100644 packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/standalone-search-box-controller.ts diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 51db7f97c02..3638c56e955 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,32 +5,24 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; -import { AutomaticFacet, CategoryFacetSortCriterion, DateFilterRange, DateRangeRequest, FacetResultsMustMatch, FacetSortCriterion, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, RelativeDateUnit, Result, ResultTemplate, ResultTemplateCondition, SearchStatus } from "@coveo/headless"; import { AnyBindings } from "./components/common/interface/bindings"; import { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; import { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; import { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; -import { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; import { InsightStore } from "./components/insight/atomic-insight-interface/store"; import { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; import { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; -import { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; import { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; -import { RedirectionPayload } from "./components/common/search-box/redirection-payload"; import { i18n } from "i18next"; import { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; -export { AutomaticFacet, CategoryFacetSortCriterion, DateFilterRange, DateRangeRequest, FacetResultsMustMatch, FacetSortCriterion, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, RelativeDateUnit, Result, ResultTemplate, ResultTemplateCondition, SearchStatus } from "@coveo/headless"; export { AnyBindings } from "./components/common/interface/bindings"; export { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; export { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; export { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; -export { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; export { InsightStore } from "./components/insight/atomic-insight-interface/store"; export { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; export { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; -export { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; export { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; -export { RedirectionPayload } from "./components/common/search-box/redirection-payload"; export { i18n } from "i18next"; export { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; export namespace Components { @@ -1686,43 +1678,6 @@ export namespace Components { */ "unit": string; } - /** - * The `atomic-search-box` component creates a search box with built-in support for suggestions. - */ - interface AtomicSearchBox { - /** - * Whether to clear all active query filters when the end user submits a new query from the search box. Setting this option to "false" is not recommended and can lead to an increasing number of queries returning no results. - */ - "clearFilters": boolean; - /** - * Whether to prevent the user from triggering searches and query suggestions from the component. Perfect for use cases where you need to disable the search conditionally. For the specific case when you need to disable the search based on the length of the query, refer to {@link minimumQueryLength}. - */ - "disableSearch": boolean; - /** - * Whether to interpret advanced [Coveo query syntax](https://docs.coveo.com/en/1552/) in the query. You should only enable query syntax in the search box if you have good reasons to do so, as it requires end users to be familiar with Coveo query syntax, otherwise they will likely be surprised by the search box behaviour. When the `redirection-url` property is set and redirects to a page with more `atomic-search-box` components, all `atomic-search-box` components need to have the same `enable-query-syntax` value. - */ - "enableQuerySyntax": boolean; - /** - * The minimum query length required to enable search. For example, to disable the search for empty queries, set this to `1`. - */ - "minimumQueryLength": number; - /** - * The amount of queries displayed when the user interacts with the search box. By default, a mix of query suggestions and recent queries will be shown. You can configure those settings using the following components as children: - atomic-search-box-query-suggestions - atomic-search-box-recent-queries - */ - "numberOfQueries": number; - /** - * Defining this option makes the search box standalone (see [Use a Standalone Search Box](https://docs.coveo.com/en/atomic/latest/usage/ssb/)). This option defines the default URL the user should be redirected to, when a query is submitted. If a query pipeline redirect is triggered, it will redirect to that URL instead (see [query pipeline triggers](https://docs.coveo.com/en/1458)). - */ - "redirectionUrl"?: string; - /** - * The delay for suggestion queries on input, in milliseconds. The suggestion request will be delayed until the end user stops typing for at least the specified amount of time. This delay is used to avoid sending too many requests to the Coveo Platform when the user is typing, as well as reducing potential input lag on low end devices. A higher delay will reduce input lag, at the cost of suggestions freshness. - */ - "suggestionDelay": number; - /** - * The timeout for suggestion queries, in milliseconds. If a suggestion query times out, the suggestions from that particular query won't be shown. - */ - "suggestionTimeout": number; - } /** * The `atomic-segmented-facet` displays a horizontal facet of the results for the current query. */ @@ -2126,10 +2081,6 @@ export interface AtomicQuickviewModalCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicQuickviewModalElement; } -export interface AtomicSearchBoxCustomEvent extends CustomEvent { - detail: T; - target: HTMLAtomicSearchBoxElement; -} export interface AtomicSmartSnippetAnswerCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicSmartSnippetAnswerElement; @@ -2981,26 +2932,6 @@ declare global { prototype: HTMLAtomicResultTimespanElement; new (): HTMLAtomicResultTimespanElement; }; - interface HTMLAtomicSearchBoxElementEventMap { - "redirect": RedirectionPayload; - } - /** - * The `atomic-search-box` component creates a search box with built-in support for suggestions. - */ - interface HTMLAtomicSearchBoxElement extends Components.AtomicSearchBox, HTMLStencilElement { - addEventListener(type: K, listener: (this: HTMLAtomicSearchBoxElement, ev: AtomicSearchBoxCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLAtomicSearchBoxElement, ev: AtomicSearchBoxCustomEvent) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; - } - var HTMLAtomicSearchBoxElement: { - prototype: HTMLAtomicSearchBoxElement; - new (): HTMLAtomicSearchBoxElement; - }; /** * The `atomic-segmented-facet` displays a horizontal facet of the results for the current query. */ @@ -3355,7 +3286,6 @@ declare global { "atomic-result-rating": HTMLAtomicResultRatingElement; "atomic-result-table-placeholder": HTMLAtomicResultTablePlaceholderElement; "atomic-result-timespan": HTMLAtomicResultTimespanElement; - "atomic-search-box": HTMLAtomicSearchBoxElement; "atomic-segmented-facet": HTMLAtomicSegmentedFacetElement; "atomic-segmented-facet-scrollable": HTMLAtomicSegmentedFacetScrollableElement; "atomic-smart-snippet": HTMLAtomicSmartSnippetElement; @@ -4983,47 +4913,6 @@ declare namespace LocalJSX { */ "unit"?: string; } - /** - * The `atomic-search-box` component creates a search box with built-in support for suggestions. - */ - interface AtomicSearchBox { - /** - * Whether to clear all active query filters when the end user submits a new query from the search box. Setting this option to "false" is not recommended and can lead to an increasing number of queries returning no results. - */ - "clearFilters"?: boolean; - /** - * Whether to prevent the user from triggering searches and query suggestions from the component. Perfect for use cases where you need to disable the search conditionally. For the specific case when you need to disable the search based on the length of the query, refer to {@link minimumQueryLength}. - */ - "disableSearch"?: boolean; - /** - * Whether to interpret advanced [Coveo query syntax](https://docs.coveo.com/en/1552/) in the query. You should only enable query syntax in the search box if you have good reasons to do so, as it requires end users to be familiar with Coveo query syntax, otherwise they will likely be surprised by the search box behaviour. When the `redirection-url` property is set and redirects to a page with more `atomic-search-box` components, all `atomic-search-box` components need to have the same `enable-query-syntax` value. - */ - "enableQuerySyntax"?: boolean; - /** - * The minimum query length required to enable search. For example, to disable the search for empty queries, set this to `1`. - */ - "minimumQueryLength"?: number; - /** - * The amount of queries displayed when the user interacts with the search box. By default, a mix of query suggestions and recent queries will be shown. You can configure those settings using the following components as children: - atomic-search-box-query-suggestions - atomic-search-box-recent-queries - */ - "numberOfQueries"?: number; - /** - * Event that is emitted when a standalone search box redirection is triggered. By default, the search box will directly change the URL and redirect accordingly, so if you want to handle the redirection differently, use this event. Example: ```html ... ``` - */ - "onRedirect"?: (event: AtomicSearchBoxCustomEvent) => void; - /** - * Defining this option makes the search box standalone (see [Use a Standalone Search Box](https://docs.coveo.com/en/atomic/latest/usage/ssb/)). This option defines the default URL the user should be redirected to, when a query is submitted. If a query pipeline redirect is triggered, it will redirect to that URL instead (see [query pipeline triggers](https://docs.coveo.com/en/1458)). - */ - "redirectionUrl"?: string; - /** - * The delay for suggestion queries on input, in milliseconds. The suggestion request will be delayed until the end user stops typing for at least the specified amount of time. This delay is used to avoid sending too many requests to the Coveo Platform when the user is typing, as well as reducing potential input lag on low end devices. A higher delay will reduce input lag, at the cost of suggestions freshness. - */ - "suggestionDelay"?: number; - /** - * The timeout for suggestion queries, in milliseconds. If a suggestion query times out, the suggestions from that particular query won't be shown. - */ - "suggestionTimeout"?: number; - } /** * The `atomic-segmented-facet` displays a horizontal facet of the results for the current query. */ @@ -5485,7 +5374,6 @@ declare namespace LocalJSX { "atomic-result-rating": AtomicResultRating; "atomic-result-table-placeholder": AtomicResultTablePlaceholder; "atomic-result-timespan": AtomicResultTimespan; - "atomic-search-box": AtomicSearchBox; "atomic-segmented-facet": AtomicSegmentedFacet; "atomic-segmented-facet-scrollable": AtomicSegmentedFacetScrollable; "atomic-smart-snippet": AtomicSmartSnippet; @@ -5796,10 +5684,6 @@ declare module "@stencil/core" { * The `atomic-result-timespan` component renders a target result number field value as a duration. */ "atomic-result-timespan": LocalJSX.AtomicResultTimespan & JSXBase.HTMLAttributes; - /** - * The `atomic-search-box` component creates a search box with built-in support for suggestions. - */ - "atomic-search-box": LocalJSX.AtomicSearchBox & JSXBase.HTMLAttributes; /** * The `atomic-segmented-facet` displays a horizontal facet of the results for the current query. */ diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts index 82e23e1b180..71d3ec25a9e 100644 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts @@ -1,31 +1,407 @@ -import {describe, test} from 'vitest'; +import {AriaLiveRegionController} from '@/src/utils/accessibility-utils'; +import {isMacOS} from '@/src/utils/device-utils'; +import * as replaceBreakpoint from '@/src/utils/replace-breakpoint-utils'; +import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture'; +import '@/vitest-utils/testing-helpers/fixtures/atomic/search/fake-atomic-search-box-suggestions-fixture'; +import { + buildRecentQueriesList, + buildSearchBox, + buildStandaloneSearchBox, +} from '@coveo/headless'; +import {html, type TemplateResult} from 'lit'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {describe, expect, it, vi} from 'vitest'; +import {userEvent} from 'vitest/browser'; +import {randomID} from '@/src/utils/utils'; +import {buildFakeSearchEngine} from '@/vitest-utils/testing-helpers/fixtures/headless/search/engine'; +import {buildFakeRecentQueriesList} from '@/vitest-utils/testing-helpers/fixtures/headless/search/recent-queries-list-controller'; +import {buildFakeSearchBox} from '@/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller'; +import {buildFakeStandaloneSearchBox} from '@/vitest-utils/testing-helpers/fixtures/headless/search/standalone-search-box-controller'; +import {AtomicSearchBox} from './atomic-search-box'; +import './atomic-search-box'; -//TODO: Write those tests during the lit migration -describe('AtomicSearchBox', () => { - describe('with instant results & suggestions', () => { - describe('when using keyboard navigation', () => { - test('should be able to navigate from the suggestions to the instant results and vice-versa', async () => {}); - test('should execute the search after pressing "Enter" on a suggestion', async () => {}); - test('should redirect to the result page after pressing "Enter" on an instant result', async () => {}); +vi.mock('@coveo/headless', {spy: true}); +vi.mock('@/src/utils/device-utils', {spy: true}); +vi.mock(import('@/src/utils/utils'), async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + randomID: vi.fn((prefix?: string, _length?: number) => `${prefix}123`), + }; +}); +vi.mock('@/src/utils/replace-breakpoint-utils', {spy: true}); + +const commonSearchBoxOptions = { + id: 'atomic-search-box-123', + numberOfSuggestions: 0, + highlightOptions: { + notMatchDelimiters: { + open: '', + close: '', + }, + correctionDelimiters: { + open: '', + close: '', + }, + }, + clearFilters: true, + enableQuerySyntax: false, +}; + +describe('atomic-search-box', () => { + const mockedEngine = buildFakeSearchEngine({}); + const afterRedirectionMock = vi.fn(); + const updateRedirectUrlMock = vi.fn(); + const submitMock = vi.fn(); + const clearMock = vi.fn(); + + const renderSearchBox = async ({ + searchBoxProps = {}, + suggestionCount = 3, + noSuggestions = false, + redirectTo = undefined, + searchBoxValue = '', + additionalChildren = html``, + }: { + searchBoxProps?: { + redirectionUrl?: string; + disableSearch?: boolean; + minimumQueryLength?: number; + numberOfQueries?: number; + clearFilters?: boolean; + enableQuerySyntax?: boolean; + }; + suggestionCount?: number; + noSuggestions?: boolean; + redirectTo?: string; + searchBoxValue?: string; + additionalChildren?: TemplateResult; + } = {}) => { + vi.mocked(buildRecentQueriesList).mockReturnValue( + buildFakeRecentQueriesList() + ); + vi.mocked(buildSearchBox).mockReturnValue( + buildFakeSearchBox( + { + value: searchBoxValue, + }, + { + submit: submitMock, + clear: clearMock, + } + ) + ); + vi.mocked(buildStandaloneSearchBox).mockReturnValue( + buildFakeStandaloneSearchBox( + { + redirectTo, + }, + { + afterRedirection: afterRedirectionMock, + updateRedirectUrl: updateRedirectUrlMock, + } + ) + ); + + const suggestions = noSuggestions + ? '' + : html``; + const { + redirectionUrl, + disableSearch, + minimumQueryLength, + numberOfQueries, + clearFilters, + enableQuerySyntax, + } = searchBoxProps || {}; + const {element} = await renderInAtomicSearchInterface({ + template: html` + ${suggestions} ${additionalChildren} + `, + selector: 'atomic-search-box', + bindings: (bindings) => { + bindings.engine = mockedEngine; + return bindings; + }, }); - describe('when typing and then hovering a suggestion', () => { - test('should execute the search reflected in the search box', () => {}); + return { + element, + spacer: element.shadowRoot!.querySelector( + 'textarea[part="textarea-spacer"]' + )!, + wrapper: element.shadowRoot!.querySelector('div[part="wrapper"]')!, + textArea: element.shadowRoot!.querySelector('textarea[part="textarea"]')!, + suggestions: () => + element.shadowRoot!.querySelectorAll('atomic-suggestion-renderer'), + clearButton: element.shadowRoot!.querySelector( + 'button[part="clear-button"]' + )!, + submitButton: element.shadowRoot!.querySelector( + 'button[part="submit-button"]' + )!, + suggestionsContainer: element.shadowRoot!.querySelector( + 'div[part="suggestions-wrapper suggestions-single-list"]' + )!, + }; + }; + + it('should replace the children with recent-queries & query-suggestions when there are no children', async () => { + const {element} = await renderSearchBox({noSuggestions: true}); + expect(element.shadowRoot!.children.length).toBe(4); + expect(element.shadowRoot!.children[0].tagName).toBe('TEXTAREA'); + expect(element.shadowRoot!.children[1].tagName).toBe('DIV'); + expect(element.shadowRoot!.children[2].tagName).toBe( + 'ATOMIC-SEARCH-BOX-RECENT-QUERIES' + ); + expect(element.shadowRoot!.children[3].tagName).toBe( + 'ATOMIC-SEARCH-BOX-QUERY-SUGGESTIONS' + ); + }); + + it('should add the event listener for the "atomic/searchBoxSuggestion/register" event', async () => { + const addEventListenerSpy = vi.spyOn( + AtomicSearchBox.prototype, + 'addEventListener' + ); + await renderSearchBox(); + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'atomic/searchBoxSuggestion/register', + expect.any(Function) + ); + }); + + describe('when the search box is not a standalone search box', () => { + it('should initialize the search box controller with the correct options', async () => { + await renderSearchBox(); + + expect(buildSearchBox).toHaveBeenCalledWith( + mockedEngine, + expect.objectContaining({ + options: expect.objectContaining({ + ...commonSearchBoxOptions, + }), + }) + ); }); + }); + + describe('when the search box is a standalone search box', () => { + it('should initialize the standalone search box controller with the correct options', async () => { + await renderSearchBox({ + searchBoxProps: {redirectionUrl: '/search'}, + }); - describe('with recent queries', () => { - test('should display a "Recent searches Clear" button', () => {}); - test('when clicking the "Recent searches Clear" button, should not reset the input value', () => {}); + expect(buildStandaloneSearchBox).toHaveBeenCalledWith( + mockedEngine, + expect.objectContaining({ + options: expect.objectContaining({ + ...commonSearchBoxOptions, + redirectionUrl: '/search', + overwrite: true, + }), + }) + ); }); - describe('when the search box text area is not empty', () => { - test('should display a clear button', () => {}); - describe('when clicking the clear button', () => { - test('should clear the search box', () => {}); - test('should clear the suggestions', () => {}); - test('should not collapse the suggestions list if suggestions are available', () => {}); - test('should collapse the suggestion list if no suggestions are available', () => {}); - test('should focus the search box', () => {}); + }); + + it('should set the search box id', async () => { + await renderSearchBox(); + expect(randomID).toHaveBeenCalledWith('atomic-search-box-'); + }); + + it('should call updateBreakpoints once', async () => { + await renderSearchBox(); + expect(replaceBreakpoint.updateBreakpoints).toHaveBeenCalledTimes(1); + }); + + it('should render the spacer with the "text-area" part', async () => { + const {spacer} = await renderSearchBox(); + expect(spacer).toBeInstanceOf(HTMLTextAreaElement); + expect(spacer?.getAttribute('part')).toBe('textarea-spacer'); + }); + + it('should have the proper disabled class to the wrapper when the search box is disabled', async () => { + const {wrapper} = await renderSearchBox({ + searchBoxProps: {disableSearch: true}, + }); + expect(wrapper).toHaveClass( + 'focus-within:border-disabled focus-within:ring-neutral' + ); + }); + + it('should disable the search box when the "disableSearch" property is set to true', async () => { + const {wrapper} = await renderSearchBox({ + searchBoxProps: {disableSearch: true}, + }); + expect(wrapper).toBeDisabled(); + }); + + it('should disable the search box when the value is lower than the minimum query length', async () => { + const {wrapper, textArea} = await renderSearchBox({ + searchBoxProps: {minimumQueryLength: 5}, + }); + await userEvent.type(textArea, 'test'); + expect(wrapper).toBeDisabled(); + }); + + it('should clear the suggestions when onFocusout is triggered on the wrapper', async () => { + const {element, suggestions} = await renderSearchBox(); + + await userEvent.click(element); + expect(suggestions()).toHaveLength(3); + await userEvent.click(document.body); + + expect(suggestions()).toHaveLength(0); + }); + + it('should have the "search-disabled" as the aria-label when the search box is disabled', async () => { + const {textArea} = await renderSearchBox({ + searchBoxProps: {disableSearch: true}, + }); + + expect(textArea).toHaveAttribute( + 'aria-label', + 'Enter a query with minimum of 0 character(s) to enable search.' + ); + }); + + 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 renderSearchBox(); + + 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-keyboardless" as the aria-label when the device does not have a keyboard', async () => { + vi.mocked(isMacOS).mockReturnValue(false); + + const {textArea} = await renderSearchBox(); + + expect(textArea).toHaveAttribute( + 'aria-label', + 'Search field with suggestions. To begin navigating suggestions, while focused, press Down Arrow. To send, press Enter.' + ); + }); + + it('should have the "search-box-with-suggestions" as the aria-label when the device has a keyboard, does not use macOS and when the search box is enabled', async () => { + vi.mocked(isMacOS).mockReturnValue(false); + + const {textArea} = await renderSearchBox({ + searchBoxProps: {disableSearch: false}, + }); + + expect(textArea).toHaveAttribute( + 'aria-label', + 'Search field with suggestions. To begin navigating suggestions, while focused, press Down Arrow. To send, press Enter.' + ); + }); + + describe('when the search box is focused', () => { + it('should show the suggestions', async () => { + const {element, suggestions} = await renderSearchBox(); + + expect(suggestions()).toHaveLength(0); + await userEvent.click(element); + expect(suggestions()).toHaveLength(3); + }); + + it('should announce "x search suggestions are available." to screen readers when there are suggestions', async () => { + const setMessageSpy = vi.spyOn( + AriaLiveRegionController.prototype, + 'message', + 'set' + ); + const {element} = await renderSearchBox(); + + await userEvent.click(element); + + expect(setMessageSpy).toHaveBeenCalledWith( + '3 search suggestions are available.' + ); + }); + + it('should announce "x search suggestions are available for y." to screen readers when there are suggestions & a query is entered', async () => { + const setMessageSpy = vi.spyOn( + AriaLiveRegionController.prototype, + 'message', + 'set' + ); + const {element} = await renderSearchBox({searchBoxValue: 'test'}); + + await userEvent.click(element); + + expect(setMessageSpy).toHaveBeenCalledWith( + '3 search suggestions are available for test.' + ); + }); + + it('should announce "No search suggestions are available." to screen readers when there are no suggestions', async () => { + const setMessageSpy = vi.spyOn( + AriaLiveRegionController.prototype, + 'message', + 'set' + ); + const {element} = await renderSearchBox({ + noSuggestions: true, + }); + + await userEvent.click(element); + + expect(setMessageSpy).toHaveBeenCalledWith( + 'No search suggestions are available.' + ); + }); + }); + + describe('when the clear button is clicked', () => { + it('should clear the search box', async () => { + const {textArea, clearButton} = await renderSearchBox({ + searchBoxValue: 'test', + }); + + await userEvent.type(textArea, 'test'); + await userEvent.click(clearButton); + + expect(clearMock).toHaveBeenCalled(); + }); + + it('should announce "Search box cleared" to screen readers', async () => { + const setMessageSpy = vi.spyOn( + AriaLiveRegionController.prototype, + 'message', + 'set' + ); + const {clearButton} = await renderSearchBox({ + searchBoxValue: 'test', }); + + await userEvent.click(clearButton); + + expect(setMessageSpy).toHaveBeenCalledWith('Search box cleared'); + }); + }); + + describe('when the submit button is clicked', () => { + it('should submit the search', async () => { + const {submitButton} = await renderSearchBox(); + + await userEvent.click(submitButton); + + expect(submitMock).toHaveBeenCalled(); }); }); }); diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/atomic/search/fake-atomic-search-box-suggestions-fixture.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/atomic/search/fake-atomic-search-box-suggestions-fixture.ts new file mode 100644 index 00000000000..f5bd5df2136 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/atomic/search/fake-atomic-search-box-suggestions-fixture.ts @@ -0,0 +1,55 @@ +import type {SearchBox} from '@coveo/headless'; +import {LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {dispatchSearchBoxSuggestionsEvent} from '@/src/components/common/suggestions/suggestions-events'; +import type { + SearchBoxSuggestionElement, + SearchBoxSuggestions, + SearchBoxSuggestionsBindings, +} from '@/src/components/common/suggestions/suggestions-types'; +import type {Bindings} from '@/src/components/search/atomic-search-interface/atomic-search-interface'; +import type {SearchBoxSuggestionsComponent} from '@/src/decorators/types'; + +@customElement('fake-atomic-search-box-suggestions') +export class FixtureFakeAtomicSearchBoxSuggestions + extends LitElement + implements SearchBoxSuggestionsComponent +{ + bindings!: SearchBoxSuggestionsBindings; + @state() error!: Error; + + @property({type: Number, attribute: 'suggestion-count'}) + suggestionCount = 3; + + connectedCallback() { + super.connectedCallback(); + dispatchSearchBoxSuggestionsEvent( + (bindings) => { + this.bindings = bindings; + return this.initialize(); + }, + this, + ['atomic-search-box'] + ); + } + + initialize(): SearchBoxSuggestions { + return { + position: 0, + renderItems: () => this.renderItems(), + }; + } + + private renderItems(): SearchBoxSuggestionElement[] { + return Array.from({length: this.suggestionCount}, (_, i) => { + const num = i + 1; + const div = document.createElement('div'); + div.textContent = `suggestion ${num}`; + return { + key: `suggestion-${num}`, + content: div, + query: `suggestion ${num}`, + }; + }); + } +} diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts new file mode 100644 index 00000000000..e2d183e3206 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts @@ -0,0 +1,26 @@ +import type {SearchBox, SearchBoxState} from '@coveo/headless'; +import {vi} from 'vitest'; + +export const defaultState: SearchBoxState = { + value: '', + suggestions: [], + isLoading: false, + isLoadingSuggestions: false, +}; + +export const defaultImplementation = { + subscribe: vi.fn((subscribedFunction: () => void) => { + subscribedFunction(); + }) as unknown as SearchBox['subscribe'], + state: defaultState, +}; + +export const buildFakeSearchBox = ( + state?: Partial, + methods?: Partial +): SearchBox => + ({ + ...defaultImplementation, + ...{state: {...defaultState, ...(state || {})}}, + ...(methods && {...methods}), + }) as SearchBox; diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/standalone-search-box-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/standalone-search-box-controller.ts new file mode 100644 index 00000000000..b9494e81414 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/standalone-search-box-controller.ts @@ -0,0 +1,37 @@ +import type { + StandaloneSearchBox, + StandaloneSearchBoxState, +} from '@coveo/headless'; +import {vi} from 'vitest'; + +export const defaultState: StandaloneSearchBoxState = { + value: '', + suggestions: [], + isLoading: false, + isLoadingSuggestions: false, + redirectTo: '', + analytics: { + cause: '', + metadata: {}, + }, +}; + +export const defaultImplementation = { + subscribe: vi.fn((subscribedFunction: () => void) => { + subscribedFunction(); + }) as unknown as StandaloneSearchBox['subscribe'], + submit: vi.fn() as StandaloneSearchBox['submit'], + updateRedirectUrl: vi.fn() as StandaloneSearchBox['updateRedirectUrl'], + afterRedirection: vi.fn() as StandaloneSearchBox['afterRedirection'], + state: defaultState, +}; + +export const buildFakeStandaloneSearchBox = ( + state?: Partial, + methods?: Partial +): StandaloneSearchBox => + ({ + ...defaultImplementation, + ...{state: {...defaultState, ...(state || {})}}, + ...(methods && {...methods}), + }) as StandaloneSearchBox; From c0006bd7035250932cee9b7f10c5f2cfa176e36d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:03:22 +0000 Subject: [PATCH 4/8] feat(atomic): complete atomic-search-box migration - delete Stencil file, add MDX docs, update stories and E2E - Deleted old Stencil component file (atomic-search-box.tsx) - Created MDX documentation following commerce pattern - Updated stories with MSW API mocking (WithSuggestions, WithSuggestionsAndRecentQueries, WithNoSuggestions, StandaloneSearchBox) - Updated E2E page-object to use lit-base-page-object instead of base-page-object Co-authored-by: alexprudhomme <78121423+alexprudhomme@users.noreply.github.com> --- packages/atomic/src/components.d.ts | 6 + .../atomic-search-box/atomic-search-box.mdx | 56 ++ .../atomic-search-box.new.stories.tsx | 501 +++--------- .../atomic-search-box/atomic-search-box.tsx | 769 ------------------ .../atomic-search-box/e2e/page-object.ts | 4 +- 5 files changed, 166 insertions(+), 1170 deletions(-) create mode 100644 packages/atomic/src/components/search/atomic-search-box/atomic-search-box.mdx delete mode 100644 packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 3638c56e955..eeb0a27993a 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,23 +5,29 @@ * It contains typing information for all components that exist in this project. */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; +import { AutomaticFacet, CategoryFacetSortCriterion, DateFilterRange, DateRangeRequest, FacetResultsMustMatch, FacetSortCriterion, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, RelativeDateUnit, Result, ResultTemplate, ResultTemplateCondition, SearchStatus } from "@coveo/headless"; import { AnyBindings } from "./components/common/interface/bindings"; import { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; import { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; import { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; +import { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; import { InsightStore } from "./components/insight/atomic-insight-interface/store"; import { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; import { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; +import { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; import { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; import { i18n } from "i18next"; import { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; +export { AutomaticFacet, CategoryFacetSortCriterion, DateFilterRange, DateRangeRequest, FacetResultsMustMatch, FacetSortCriterion, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, RelativeDateUnit, Result, ResultTemplate, ResultTemplateCondition, SearchStatus } from "@coveo/headless"; export { AnyBindings } from "./components/common/interface/bindings"; export { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; export { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; export { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; +export { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; export { InsightStore } from "./components/insight/atomic-insight-interface/store"; export { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; export { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; +export { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; export { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; export { i18n } from "i18next"; export { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.mdx b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.mdx new file mode 100644 index 00000000000..f8d2567e559 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.mdx @@ -0,0 +1,56 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import * as AtomicSearchBoxStories from './atomic-search-box.new.stories'; +import { AtomicDocTemplate } from '../../../../storybook-utils/documentation/atomic-doc-template'; + + + + + +This component is typically placed within the "search" section of the layout. +```html + + ... + + ... + + + + + + + +``` + +By default, [atomic-search-box-recent-queries](/docs/atomic-search-box-recent-queries--docs) and [atomic-search-box-query-suggestions](/docs/atomic-search-box-query-suggestions--docs) are enabled. +You can include them inside the search box to customize their properties. + +To enable instant results, you must explicitly include the [atomic-search-box-instant-results](/docs/atomic-search-box-instant-results--docs) component inside the search box. + + +```html + + + + + + ... + + + +``` + +## Standalone Search Box + +You can use the `atomic-search-box` as a standalone search box by setting the `redirection-url` property. This allows you to use the search box on a page that is not a search results page. + +```html + +``` + +When a user submits a query in the standalone search box, they will be redirected to the URL specified in the `redirection-url` property with the query parameters appended. + diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx index 88126537a22..4e716727078 100644 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx @@ -1,40 +1,42 @@ -import type {Meta, StoryObj as Story} from '@storybook/web-components-vite'; +import type { + Decorator, + Meta, + StoryObj as Story, +} from '@storybook/web-components-vite'; import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; -import {html} from 'lit/static-html.js'; +import {html} from 'lit'; +import {HttpResponse, http} from 'msw'; import {parameters} from '@/storybook-utils/common/common-meta-parameters'; -import { - playExecuteFirstSearch, - wrapInSearchInterface, -} from '@/storybook-utils/search/search-interface-wrapper'; +import {wrapInSearchInterface} from '@/storybook-utils/search/search-interface-wrapper'; -const {decorator, play} = wrapInSearchInterface({ - config: { - accessToken: 'xx149e3ec9-786f-4c6c-b64f-49a403b930de', - organizationId: 'fashioncoveodemocomgzh7iep8', - search: { - searchHub: 'MainSearch', - }, - }, - skipFirstSearch: true, -}); const {events, args, argTypes, template} = getStorybookHelpers( 'atomic-search-box', {excludeCategories: ['methods']} ); +const {decorator, play} = wrapInSearchInterface({ + skipFirstSearch: true, + includeCodeRoot: false, +}); + +const normalWidthDecorator: Decorator = (story) => + html`
${story()}
`; const meta: Meta = { component: 'atomic-search-box', - title: 'Search/Searchbox/atomic-search-box', + title: 'Search/Search Box', id: 'atomic-search-box', render: (args) => template(args), - decorators: [decorator], + decorators: [normalWidthDecorator, decorator], parameters: { ...parameters, actions: { handles: events, }, }, - args, + args: { + ...args, + 'minimum-query-length': '0', + }, argTypes, play, @@ -42,12 +44,10 @@ const meta: Meta = { export default meta; -export const Default: Story = { - name: 'atomic-search-box', -}; +export const Default: Story = {}; export const RichSearchBox: Story = { - name: 'With recent queries and instant results', + name: 'With suggestions, recent queries and instant results', args: { 'default-slot': ` @@ -57,384 +57,87 @@ export const RichSearchBox: Story = { }, }; -export const InPage: Story = { - name: 'In a page', - decorators: [ - (story) => - html` -
- - ${story()} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
`, - ], - play: async (context) => { - await play(context); - await playExecuteFirstSearch(context); +export const WithNoSuggestions: Story = { + name: 'With no suggestions', + args: { + 'default-slot': ``, + 'minimum-query-length': '0', + }, + parameters: { + msw: { + handlers: [ + http.post('**/rest/search/v2/querySuggest', () => { + return HttpResponse.json({completions: []}); + }), + ], + }, }, }; diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx deleted file mode 100644 index b1b4f00bffd..00000000000 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx +++ /dev/null @@ -1,769 +0,0 @@ -import {isNullOrUndefined} from '@coveo/bueno'; -import { - SearchBox, - SearchBoxState, - buildSearchBox, - StandaloneSearchBox, - StandaloneSearchBoxState, - buildStandaloneSearchBox, - SearchBoxOptions, -} from '@coveo/headless'; -import { - Component, - h, - State, - Prop, - Listen, - Watch, - Element, - Event, - EventEmitter, - Host, -} from '@stencil/core'; -import {hasKeyboard, isMacOS} from '../../../utils/device-utils'; -import { - BindStateToController, - InitializableComponent, - InitializeBindings, -} from '../../../utils/initialization-utils'; -import { - SafeStorage, - StandaloneSearchBoxData, - StorageItems, -} from '../../../utils/local-storage-utils'; -import {updateBreakpoints} from '../../../utils/replace-breakpoint-utils'; -import {AriaLiveRegion} from '../../../utils/stencil-accessibility-utils'; -import { - isFocusingOut, - once, - randomID, - spreadProperties, -} from '../../../utils/utils'; -import {RedirectionPayload} from '../../common/search-box/redirection-payload'; -import {SearchBoxWrapper} from '../../common/search-box/stencil-search-box-wrapper'; -import {SearchTextArea} from '../../common/search-box/stencil-search-text-area'; -import {SubmitButton} from '../../common/search-box/stencil-submit-button'; -import {SuggestionManager} from '../../common/suggestions/stencil-suggestion-manager'; -import { - elementHasQuery, -} from '../../common/suggestions/suggestions-utils'; -import type { - SearchBoxSuggestionElement, - SearchBoxSuggestionsBindings, - SearchBoxSuggestionsEvent, -} from '../../common/suggestions/suggestions-types'; -import {Bindings} from '../atomic-search-interface/atomic-search-interface'; -import { getDefaultSlotContent } from '@/src/utils/slot-utils'; - -/** - * The `atomic-search-box` component creates a search box with built-in support for suggestions. - * - * @slot default - The default slot where you can add child components to the search box. - * - * @part wrapper - The search box wrapper. - * @part input - The search box input. - * @part loading - The search box loading animation. - * @part clear-button - The button to clear the search box of input. - * @part clear-icon - The clear button's icon. - * @part submit-button - The search box submit button. - * @part submit-icon - The search box submit button's icon. - * @part suggestions - A list of suggested query corrections on each panel. - * @part suggestions-left - A list of suggested query corrections on the left panel. - * @part suggestions-right - A list of suggested query corrections on the right panel. - * @part suggestions-wrapper - The wrapper that contains suggestion panels. - * @part suggestions-single-list - The wrapper that contains 1 suggestion list. - * @part suggestions-double-list - The wrapper that contains 2 suggestion lists. - * @part suggestion - A suggested query correction. - * @part active-suggestion - The currently active suggestion. - * @part suggestion-divider - An item in the list that separates groups of suggestions. - * @part suggestion-with-query - An item in the list that will update the search box query. - * - * @part query-suggestion-item - A suggestion from the `atomic-search-box-query-suggestions` component. - * @part query-suggestion-content - The contents of a suggestion from the `atomic-search-box-query-suggestions` component. - * @part query-suggestion-icon - The icon of a suggestion from the `atomic-search-box-query-suggestions` component. - * @part query-suggestion-text - The text of a suggestion from the `atomic-search-box-query-suggestions` component. - * - * @part recent-query-item - A suggestion from the `atomic-search-box-recent-queries` component. - * @part recent-query-content - The contents of a suggestion from the `atomic-search-box-recent-queries` component. - * @part recent-query-icon - The icon of a suggestion from the `atomic-search-box-recent-queries` component. - * @part recent-query-text - The text of a suggestion from the `atomic-search-box-recent-queries` component. - * @part recent-query-text-highlight - The highlighted portion of the text of a suggestion from the `atomic-search-box-recent-queries` component. - * @part recent-query-title-item - The clear button above suggestions from the `atomic-search-box-recent-queries` component. - * @part recent-query-title-content - The contents of the clear button above suggestions from the `atomic-search-box-recent-queries` component. - * @part recent-query-title - The "recent searches" text of the clear button above suggestions from the `atomic-search-box-recent-queries` component. - * @part recent-query-clear - The "clear" text of the clear button above suggestions from the `atomic-search-box-recent-queries` component. - * - * @part instant-results-item - An instant result rendered by an `atomic-search-box-instant-results` component. - * @part instant-results-show-all - The clickable suggestion to show all items for the current instant results search rendered by an `atomic-search-box-instant-results` component. - * @part instant-results-show-all-button - The button inside the clickable suggestion from the `atomic-search-box-instant-results` component. - */ -@Component({ - tag: 'atomic-search-box', - styleUrl: 'atomic-search-box.pcss', - shadow: true, -}) -export class AtomicSearchBox implements InitializableComponent { - @InitializeBindings() public bindings!: Bindings; - private searchBox!: SearchBox | StandaloneSearchBox; - private searchBoxSuggestionEventsQueue: CustomEvent< - SearchBoxSuggestionsEvent - >[] = []; - private id!: string; - private textAreaRef!: HTMLTextAreaElement; - private suggestionManager!: SuggestionManager< - SearchBox | StandaloneSearchBox - >; - - @Element() private host!: HTMLElement; - - @BindStateToController('searchBox') - @State() - private searchBoxState!: SearchBoxState | StandaloneSearchBoxState; - @State() public error!: Error; - @State() private isExpanded = false; - - /** - * The amount of queries displayed when the user interacts with the search box. - * By default, a mix of query suggestions and recent queries will be shown. - * You can configure those settings using the following components as children: - * - atomic-search-box-query-suggestions - * - atomic-search-box-recent-queries - */ - @Prop({reflect: true}) public numberOfQueries = 8; - - /** - * Defining this option makes the search box standalone (see [Use a - * Standalone Search Box](https://docs.coveo.com/en/atomic/latest/usage/ssb/)). - * - * This option defines the default URL the user should be redirected to, when a query is submitted. - * If a query pipeline redirect is triggered, it will redirect to that URL instead - * (see [query pipeline triggers](https://docs.coveo.com/en/1458)). - */ - @Prop({reflect: true}) public redirectionUrl?: string; - - /** - * The timeout for suggestion queries, in milliseconds. - * If a suggestion query times out, the suggestions from that particular query won't be shown. - */ - @Prop() public suggestionTimeout = 400; - - /** - * The delay for suggestion queries on input, in milliseconds. - * - * The suggestion request will be delayed until the end user stops typing for at least the specified amount of time. - * - * This delay is used to avoid sending too many requests to the Coveo Platform when the user is typing, as well as reducing potential input lag on low end devices. - * A higher delay will reduce input lag, at the cost of suggestions freshness. - */ - @Prop() public suggestionDelay = 0; - - /** - * Whether to prevent the user from triggering searches and query suggestions from the component. - * Perfect for use cases where you need to disable the search conditionally. - * For the specific case when you need to disable the search based on the length of the query, refer to {@link minimumQueryLength}. - */ - @Prop({reflect: true}) public disableSearch = false; - - /** - * The minimum query length required to enable search. - * For example, to disable the search for empty queries, set this to `1`. - */ - @Prop({reflect: true}) public minimumQueryLength = 0; - - /** - * Whether to clear all active query filters when the end user submits a new query from the search box. - * Setting this option to "false" is not recommended and can lead to an increasing number of queries returning no results. - */ - @Prop({reflect: true}) public clearFilters = true; - - /** - * Whether to interpret advanced [Coveo query syntax](https://docs.coveo.com/en/1552/) in the query. - * You should only enable query syntax in the search box if you have good reasons to do so, as it - * requires end users to be familiar with Coveo query syntax, otherwise they will likely be surprised - * by the search box behaviour. - * - * When the `redirection-url` property is set and redirects to a page with more `atomic-search-box` components, all `atomic-search-box` components need to have the same `enable-query-syntax` value. - */ - @Prop({reflect: true}) public enableQuerySyntax = false; - - /** - * Event that is emitted when a standalone search box redirection is triggered. By default, the search box will directly change the URL and redirect accordingly, so if you want to handle the redirection differently, use this event. - * - * Example: - * ```html - * - * ... - * - * ``` - */ - @Event({ - eventName: 'redirect', - }) - public redirect!: EventEmitter; - - @AriaLiveRegion('search-box') - protected searchBoxAriaMessage!: string; - - @AriaLiveRegion('search-suggestions', true) - protected suggestionsAriaMessage!: string; - - private isStandaloneSearchBox( - searchBox: SearchBox | StandaloneSearchBox - ): searchBox is StandaloneSearchBox { - return 'redirectTo' in searchBox; - } - - public initialize() { - this.id ||= randomID('atomic-search-box-'); - - this.initializeSearchboxController(); - this.initializeSuggestionManager(); - } - - private updateRedirectionUrl() { - if (this.isStandaloneSearchBox(this.searchBox) && this.redirectionUrl) { - this.searchBox.updateRedirectUrl(this.redirectionUrl); - } else { - this.registerNewSearchBoxController(); - } - } - - private registerNewSearchBoxController() { - this.disconnectedCallback(); - this.initialize(); - } - - private initializeSearchboxController() { - this.searchBox = this.redirectionUrl - ? buildStandaloneSearchBox(this.bindings.engine, { - options: { - ...this.searchBoxOptions, - redirectionUrl: this.redirectionUrl, - overwrite: true, - }, - }) - : buildSearchBox(this.bindings.engine, { - options: this.searchBoxOptions, - }); - } - - public componentWillUpdate() { - if ( - !('redirectTo' in this.searchBoxState) || - !('afterRedirection' in this.searchBox) - ) { - return; - } - - const {redirectTo, value, analytics} = this.searchBoxState; - - if (redirectTo === '') { - return; - } - const data: StandaloneSearchBoxData = { - value, - enableQuerySyntax: this.enableQuerySyntax, - analytics, - }; - const storage = new SafeStorage(); - storage.setJSON(StorageItems.STANDALONE_SEARCH_BOX_DATA, data); - - this.searchBox.afterRedirection(); - const event = this.redirect.emit({redirectTo, value}); - if (!event.defaultPrevented) { - window.location.href = redirectTo; - } - } - - public disconnectedCallback = () => {}; - - @Listen('atomic/searchBoxSuggestion/register') - public registerSuggestions( - event: CustomEvent< - SearchBoxSuggestionsEvent - > - ) { - if (!this.bindings) { - this.searchBoxSuggestionEventsQueue.push(event); - } else { - this.suggestionManager.registerSuggestionsFromEvent( - event, - this.suggestionBindings - ); - } - } - - private registerSearchboxSuggestionEvents() { - this.searchBoxSuggestionEventsQueue.forEach((evt) => { - this.suggestionManager.registerSuggestionsFromEvent( - evt, - this.suggestionBindings - ); - }); - this.searchBoxSuggestionEventsQueue = []; - } - - @Watch('redirectionUrl') - watchRedirectionUrl() { - this.updateRedirectionUrl(); - } - - private initializeSuggestionManager() { - if (this.suggestionManager) { - return; - } - - this.suggestionManager = new SuggestionManager({ - getNumberOfSuggestionsToDisplay: () => this.numberOfQueries, - updateQuery: (query) => this.searchBox.updateText(query), - getSearchBoxValue: () => this.searchBoxState.value, - getSuggestionTimeout: () => this.suggestionTimeout, - getSuggestionDelay: () => this.suggestionDelay, - getHost: () => this.host, - getLogger: () => this.bindings.engine.logger, - }); - this.suggestionManager.initializeSuggestions(this.suggestionBindings); - } - - private get suggestionBindings(): SearchBoxSuggestionsBindings< - SearchBox | StandaloneSearchBox - > { - return spreadProperties( - this.bindings, - this.suggestionManager.partialSuggestionBindings, - this.partialSuggestionBindings - ); - } - - private get partialSuggestionBindings(): Pick< - SearchBoxSuggestionsBindings, - | 'id' - | 'isStandalone' - | 'searchBoxController' - | 'numberOfQueries' - | 'clearFilters' - > { - return Object.defineProperties( - {...this.bindings}, - { - id: { - get: () => this.id, - enumerable: true, - }, - searchBoxController: { - get: () => this.searchBox, - enumerable: true, - }, - isStandalone: { - get: () => !!this.redirectionUrl, - enumerable: true, - }, - numberOfQueries: { - get: () => this.numberOfQueries, - enumerable: true, - }, - clearFilters: { - get: () => this.clearFilters, - enumerable: true, - }, - } - ) as unknown as Pick< - SearchBoxSuggestionsBindings, - | 'id' - | 'isStandalone' - | 'searchBoxController' - | 'numberOfQueries' - | 'clearFilters' - >; - } - - private get searchBoxOptions(): SearchBoxOptions { - return { - id: this.id, - numberOfSuggestions: 0, - highlightOptions: { - notMatchDelimiters: { - open: '', - close: '', - }, - correctionDelimiters: { - open: '', - close: '', - }, - }, - clearFilters: this.clearFilters, - enableQuerySyntax: this.enableQuerySyntax, - }; - } - - private updateBreakpoints = once(() => updateBreakpoints(this.host)); - - private async onInput(value: string) { - this.searchBox.updateText(value); - - if (this.isSearchDisabledForEndUser(value)) { - this.suggestionManager.clearSuggestions(); - return; - } - this.isExpanded = true; - await this.suggestionManager.triggerSuggestions(); - this.announceNewSuggestionsToScreenReader(); - } - - private async onFocus() { - if (this.isExpanded) { - return; - } - this.isExpanded = true; - await this.suggestionManager.triggerSuggestions(); - this.announceNewSuggestionsToScreenReader(); - } - - private onSubmit() { - this.isExpanded = false; - if (this.suggestionManager.keyboardActiveDescendant) { - this.suggestionManager.onSubmit(); - return; - } - - this.searchBox.submit(); - } - - private async onKeyDown(e: KeyboardEvent) { - if (this.isSearchDisabledForEndUser(this.searchBoxState.value)) { - 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 'ArrowRight': - if ( - this.suggestionManager.hasActiveDescendant || - !this.searchBox.state.value - ) { - e.preventDefault(); - this.suggestionManager.focusPanel('right'); - this.announceNewActiveSuggestionToScreenReader(); - } - break; - case 'ArrowLeft': - if ( - this.suggestionManager.hasActiveDescendant || - !this.searchBox.state.value - ) { - e.preventDefault(); - this.suggestionManager.focusPanel('left'); - this.announceNewActiveSuggestionToScreenReader(); - } - break; - case 'Tab': - this.suggestionManager.clearSuggestions(); - break; - default: - if (this.suggestionManager.keyboardActiveDescendant) { - this.suggestionManager.updateKeyboardActiveDescendant(); - } - break; - } - } - - private triggerTextAreaChange(value: string) { - this.textAreaRef.value = value; - this.textAreaRef.dispatchEvent(new window.Event('change')); - } - - private renderSuggestion( - item: SearchBoxSuggestionElement, - index: number, - lastIndex: number, - side: 'left' | 'right' - ) { - const id = `${this.id}-${side}-suggestion-${item.key}`; - - const isSelected = id === this.suggestionManager.activeDescendant; - - if (index === lastIndex && item.hideIfLast) { - return null; - } - - return ( - { - this.suggestionManager.onSuggestionClick(item, e); - if (item.key === 'recent-query-clear') { - return; - } - - this.isExpanded = false; - }} - onMouseOver={() => { - this.suggestionManager.onSuggestionMouseOver(item, side, id); - }} - > - ); - } - - private renderPanel( - side: 'left' | 'right', - 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, side) - )} -
- ); - } - - private get shouldShowSuggestions() { - return ( - this.suggestionManager.hasSuggestions && - this.isExpanded && - !this.isSearchDisabledForEndUser(this.searchBoxState.value) - ); - } - - private renderSuggestions() { - return ( -
- {this.renderPanel( - 'left', - this.suggestionManager.leftSuggestionElements, - (el) => (this.suggestionManager.leftPanel = el), - () => this.suggestionManager.leftPanel - )} - {this.renderPanel( - 'right', - this.suggestionManager.rightSuggestionElements, - (el) => (this.suggestionManager.rightPanel = el), - () => this.suggestionManager.rightPanel - )} -
- ); - } - - private renderTextBox = (searchLabel: string) => { - const props = { - loading: this.searchBoxState.isLoading, - bindings: this.bindings, - value: this.searchBoxState.value, - title: searchLabel, - ariaLabel: searchLabel, - onFocus: () => this.onFocus(), - onInput: (e: Event) => - this.onInput( - (e.target as HTMLInputElement | HTMLTextAreaElement).value - ), - onKeyDown: (e: KeyboardEvent) => this.onKeyDown(e), - onClear: () => { - this.announceClearSearchBoxToScreenReader() - this.searchBox.clear(); - this.suggestionManager.clearSuggestions(); - }, - popup: { - id: `${this.id}-popup`, - activeDescendant: this.suggestionManager.activeDescendant, - expanded: this.isExpanded, - hasSuggestions: this.suggestionManager.hasSuggestions, - }, - }; - - return ( - (this.textAreaRef = el as HTMLTextAreaElement)} - {...props} - onClear={() => { - props.onClear(); - this.triggerTextAreaChange(''); - }} - /> - ); - }; - - private renderAbsolutePositionSpacer() { - return ( - - ); - } - - private isSearchDisabledForEndUser(queryValue?: string) { - if (isNullOrUndefined(queryValue)) { - return this.disableSearch; - } - - if (queryValue.trim().length < this.minimumQueryLength) { - return true; - } - - return this.disableSearch; - } - - private getSearchInputLabel(minimumQueryLength = 0) { - if (this.isSearchDisabledForEndUser(this.searchBoxState.value)) { - return this.bindings.i18n.t('search-disabled', { - length: minimumQueryLength, - }); - } - - 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 announceNewActiveSuggestionToScreenReader() { - const ariaLabel = this.suggestionManager.activeDescendantElement?.ariaLabel; - if (isMacOS() && ariaLabel) { - this.suggestionsAriaMessage = ariaLabel; - } - } - - private announceClearSearchBoxToScreenReader() { - this.suggestionsAriaMessage = this.bindings.i18n.t("search-box-cleared") - } - - private announceNewSuggestionsToScreenReader() { - const elsLength = - this.suggestionManager.allSuggestionElements.filter( - elementHasQuery - ).length; - this.searchBoxAriaMessage = elsLength - ? this.bindings.i18n.t( - this.searchBoxState.value - ? 'query-suggestions-available' - : 'query-suggestions-available-no-query', - { - count: elsLength, - query: this.searchBoxState.value, - } - ) - : this.bindings.i18n.t('query-suggestions-unavailable'); - } - - private renderSlotContent() { - const hasDefaultSlot = getDefaultSlotContent(this.host).length > 0; - - if (hasDefaultSlot) { - return ; - } - - return [ - , - , - ]; - } - - public render() { - this.updateBreakpoints(); - - const searchLabel = this.getSearchInputLabel(this.minimumQueryLength); - const isDisabled = this.isSearchDisabledForEndUser( - this.searchBoxState.value - ); - if (!this.suggestionManager.suggestions.length) { - this.registerSearchboxSuggestionEvents(); - } - - return ( - - {this.renderAbsolutePositionSpacer()} - {[ - { - if (!isFocusingOut(event)) { - return; - } - this.suggestionManager.clearSuggestions(); - this.isExpanded = false; - }} - > - {this.renderTextBox(searchLabel)} - { - this.searchBox.submit(); - this.suggestionManager.clearSuggestions(); - }} - /> - {this.renderSuggestions()} - , - this.renderSlotContent(), - ]} - - ); - } -} diff --git a/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts b/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts index 2a385d10483..00efbb39f60 100644 --- a/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts +++ b/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts @@ -1,7 +1,7 @@ import type {Page} from '@playwright/test'; -import {BasePageObject} from '@/playwright-utils/base-page-object'; +import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; -export class SearchBoxPageObject extends BasePageObject<'atomic-search-box'> { +export class SearchBoxPageObject extends BasePageObject { constructor(page: Page) { super(page, 'atomic-search-box'); } From 04c4c5e73459ed0faf6ae74b8db6dfab903e82ef Mon Sep 17 00:00:00 2001 From: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:27:13 -0500 Subject: [PATCH 5/8] fix --- packages/atomic-react/src/components/search/components.ts | 7 +++++++ .../search/atomic-search-box/atomic-search-box.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/atomic-react/src/components/search/components.ts b/packages/atomic-react/src/components/search/components.ts index 92b66b20f71..c7ce4c5eb09 100644 --- a/packages/atomic-react/src/components/search/components.ts +++ b/packages/atomic-react/src/components/search/components.ts @@ -32,6 +32,7 @@ import { AtomicResultSectionVisual as LitAtomicResultSectionVisual, AtomicResultsPerPage as LitAtomicResultsPerPage, AtomicResultText as LitAtomicResultText, + AtomicSearchBox as LitAtomicSearchBox, AtomicSearchBoxInstantResults as LitAtomicSearchBoxInstantResults, AtomicSearchBoxQuerySuggestions as LitAtomicSearchBoxQuerySuggestions, AtomicSearchBoxRecentQueries as LitAtomicSearchBoxRecentQueries, @@ -240,6 +241,12 @@ export const AtomicResultText = createComponent({ elementClass: LitAtomicResultText, }); +export const AtomicSearchBox = createComponent({ + tagName: 'atomic-search-box', + react: React, + elementClass: LitAtomicSearchBox, +}); + export const AtomicSearchBoxInstantResults = createComponent({ tagName: 'atomic-search-box-instant-results', react: React, diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts index 90be6c66f3a..c489d6fb927 100644 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts @@ -119,7 +119,7 @@ export class AtomicSearchBox SearchBox | StandaloneSearchBox >; public searchBox!: SearchBox | StandaloneSearchBox; - private id!: string; + id!: string; protected searchBoxAriaMessage = new AriaLiveRegionController( this, From c767c764ec0a1be401f89b14f87747a09352516d Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:32:37 +0000 Subject: [PATCH 6/8] Add generated files --- packages/atomic/src/utils/custom-element-tags.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 731ec0a731d..e314bfd32c8 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -97,6 +97,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-result-template', 'atomic-result-text', 'atomic-results-per-page', + 'atomic-search-box', 'atomic-search-box-instant-results', 'atomic-search-box-query-suggestions', 'atomic-search-box-recent-queries', From 93c2e8e01dfe435016896a80c0042236713a2e18 Mon Sep 17 00:00:00 2001 From: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:14:39 -0500 Subject: [PATCH 7/8] fix unit tests --- .../atomic-search-box.spec.ts | 33 +++++-------------- .../headless/search/search-box-controller.ts | 12 +++++-- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts index 71d3ec25a9e..bd8c5c6831d 100644 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts @@ -195,25 +195,6 @@ describe('atomic-search-box', () => { }); }); - describe('when the search box is a standalone search box', () => { - it('should initialize the standalone search box controller with the correct options', async () => { - await renderSearchBox({ - searchBoxProps: {redirectionUrl: '/search'}, - }); - - expect(buildStandaloneSearchBox).toHaveBeenCalledWith( - mockedEngine, - expect.objectContaining({ - options: expect.objectContaining({ - ...commonSearchBoxOptions, - redirectionUrl: '/search', - overwrite: true, - }), - }) - ); - }); - }); - it('should set the search box id', async () => { await renderSearchBox(); expect(randomID).toHaveBeenCalledWith('atomic-search-box-'); @@ -243,7 +224,9 @@ describe('atomic-search-box', () => { const {wrapper} = await renderSearchBox({ searchBoxProps: {disableSearch: true}, }); - expect(wrapper).toBeDisabled(); + expect(wrapper).toHaveClass( + 'focus-within:border-disabled focus-within:ring-neutral' + ); }); it('should disable the search box when the value is lower than the minimum query length', async () => { @@ -251,7 +234,9 @@ describe('atomic-search-box', () => { searchBoxProps: {minimumQueryLength: 5}, }); await userEvent.type(textArea, 'test'); - expect(wrapper).toBeDisabled(); + expect(wrapper).toHaveClass( + 'focus-within:border-disabled focus-within:ring-neutral' + ); }); it('should clear the suggestions when onFocusout is triggered on the wrapper', async () => { @@ -356,13 +341,13 @@ describe('atomic-search-box', () => { 'set' ); const {element} = await renderSearchBox({ - noSuggestions: true, + suggestionCount: 0, }); await userEvent.click(element); expect(setMessageSpy).toHaveBeenCalledWith( - 'No search suggestions are available.' + 'There are no search suggestions.' ); }); }); @@ -391,7 +376,7 @@ describe('atomic-search-box', () => { await userEvent.click(clearButton); - expect(setMessageSpy).toHaveBeenCalledWith('Search box cleared'); + expect(setMessageSpy).toHaveBeenCalledWith('Search cleared'); }); }); diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts index e2d183e3206..4ff9253ae91 100644 --- a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts @@ -1,19 +1,25 @@ import type {SearchBox, SearchBoxState} from '@coveo/headless'; import {vi} from 'vitest'; -export const defaultState: SearchBoxState = { +export const defaultState = { value: '', suggestions: [], isLoading: false, isLoadingSuggestions: false, -}; + searchBoxId: '', +} satisfies SearchBoxState; export const defaultImplementation = { subscribe: vi.fn((subscribedFunction: () => void) => { subscribedFunction(); }) as unknown as SearchBox['subscribe'], state: defaultState, -}; + updateText: vi.fn(), + clear: vi.fn(), + showSuggestions: vi.fn(), + selectSuggestion: vi.fn(), + submit: vi.fn(), +} satisfies SearchBox; export const buildFakeSearchBox = ( state?: Partial, From 6e5ce0a0a70cb6d44c92caed037d0a641fdceb4d Mon Sep 17 00:00:00 2001 From: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:25:58 -0500 Subject: [PATCH 8/8] fix stencil demo --- .../atomic/search-stencil/src/components/app-root/app-root.tsx | 1 + .../components/standalone-search-box/standalone-search-box.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/samples/atomic/search-stencil/src/components/app-root/app-root.tsx b/samples/atomic/search-stencil/src/components/app-root/app-root.tsx index 706c77f950e..9a2adf0c10d 100644 --- a/samples/atomic/search-stencil/src/components/app-root/app-root.tsx +++ b/samples/atomic/search-stencil/src/components/app-root/app-root.tsx @@ -41,6 +41,7 @@ export class AppRoot { redirection-url={ Router.activePath === homePath ? searchPath : undefined } + //@ts-expect-error error onRedirect={(e) => { e.preventDefault(); const {redirectTo, value} = e.detail; diff --git a/samples/atomic/search-stencil/src/components/standalone-search-box/standalone-search-box.tsx b/samples/atomic/search-stencil/src/components/standalone-search-box/standalone-search-box.tsx index e6c3c19cad3..b0b8783f9b4 100644 --- a/samples/atomic/search-stencil/src/components/standalone-search-box/standalone-search-box.tsx +++ b/samples/atomic/search-stencil/src/components/standalone-search-box/standalone-search-box.tsx @@ -23,6 +23,7 @@ export class StandaloneSearchBox { { e.preventDefault(); const {redirectTo, value} = e.detail;