diff --git a/packages/atomic-react/src/components/search/components.ts b/packages/atomic-react/src/components/search/components.ts index 8de00e0d8b1..f255072bda9 100644 --- a/packages/atomic-react/src/components/search/components.ts +++ b/packages/atomic-react/src/components/search/components.ts @@ -34,6 +34,7 @@ import { AtomicResultSectionVisual as LitAtomicResultSectionVisual, AtomicResultsPerPage as LitAtomicResultsPerPage, AtomicResultText as LitAtomicResultText, + AtomicSearchBox as LitAtomicSearchBox, AtomicSearchBoxInstantResults as LitAtomicSearchBoxInstantResults, AtomicSearchBoxQuerySuggestions as LitAtomicSearchBoxQuerySuggestions, AtomicSearchBoxRecentQueries as LitAtomicSearchBoxRecentQueries, @@ -255,6 +256,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.d.ts b/packages/atomic/src/components.d.ts index 874f39d2715..8890ab3ce78 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -16,7 +16,6 @@ import { Actions, InsightResultActionClickedEvent } from "./components/insight/a 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 { CategoryFacetSortCriterion, DateFilterRange, DateRangeRequest, FacetResultsMustMatch, FacetSortCriterion, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, NumericFilter, NumericFilterState, RangeFacetRangeAlgorithm, RangeFacetSortCriterion, RelativeDateUnit, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; @@ -30,7 +29,6 @@ export { Actions, InsightResultActionClickedEvent } from "./components/insight/a 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 { @@ -1662,43 +1660,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. */ @@ -2084,10 +2045,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; @@ -2916,26 +2873,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. */ @@ -3278,7 +3215,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; @@ -4881,47 +4817,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. */ @@ -5363,7 +5258,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; @@ -5660,10 +5554,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.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.spec.ts b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.spec.ts index 82e23e1b180..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 @@ -1,31 +1,392 @@ -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; + }, + }); + + 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, + }), + }) + ); + }); + }); + + 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).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 () => { + const {wrapper, textArea} = await renderSearchBox({ + searchBoxProps: {minimumQueryLength: 5}, + }); + await userEvent.type(textArea, 'test'); + expect(wrapper).toHaveClass( + 'focus-within:border-disabled focus-within:ring-neutral' + ); + }); + + 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.' + ); }); - describe('when typing and then hovering a suggestion', () => { - test('should execute the search reflected in the search box', () => {}); + 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.' + ); }); - 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', () => {}); + 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({ + suggestionCount: 0, + }); + + await userEvent.click(element); + + expect(setMessageSpy).toHaveBeenCalledWith( + 'There are no search suggestions.' + ); }); - 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', () => {}); + }); + + 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 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/src/components/search/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts similarity index 60% rename from packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx rename to packages/atomic/src/components/search/atomic-search-box/atomic-search-box.ts index b1b4f00bffd..c489d6fb927 100644 --- 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.ts @@ -1,59 +1,54 @@ import {isNullOrUndefined} from '@coveo/bueno'; import { - SearchBox, - SearchBoxState, buildSearchBox, - StandaloneSearchBox, - StandaloneSearchBoxState, buildStandaloneSearchBox, - SearchBoxOptions, + type SearchBox, + type SearchBoxOptions, + type SearchBoxState, + type StandaloneSearchBox, + type StandaloneSearchBoxState, } 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 {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, - StandaloneSearchBoxData, + type StandaloneSearchBoxData, StorageItems, -} from '../../../utils/local-storage-utils'; -import {updateBreakpoints} from '../../../utils/replace-breakpoint-utils'; -import {AriaLiveRegion} from '../../../utils/stencil-accessibility-utils'; +} 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 '../../../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'; +} 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. @@ -96,31 +91,46 @@ import { getDefaultSlotContent } from '@/src/utils/slot-utils'; * @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. */ -@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; +@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 id!: string; - private textAreaRef!: HTMLTextAreaElement; private suggestionManager!: SuggestionManager< SearchBox | StandaloneSearchBox >; + public searchBox!: SearchBox | StandaloneSearchBox; + id!: string; - @Element() private host!: HTMLElement; + protected searchBoxAriaMessage = new AriaLiveRegionController( + this, + 'search-box' + ); - @BindStateToController('searchBox') - @State() - private searchBoxState!: SearchBoxState | StandaloneSearchBoxState; - @State() public error!: Error; - @State() private isExpanded = false; + protected suggestionsAriaMessage = new AriaLiveRegionController( + this, + 'search-suggestions', + true + ); /** * The amount of queries displayed when the user interacts with the search box. @@ -129,7 +139,8 @@ export class AtomicSearchBox implements InitializableComponent { * - atomic-search-box-query-suggestions * - atomic-search-box-recent-queries */ - @Prop({reflect: true}) public numberOfQueries = 8; + @property({type: Number, attribute: 'number-of-queries', reflect: true}) + numberOfQueries = 8; /** * Defining this option makes the search box standalone (see [Use a @@ -139,13 +150,15 @@ export class AtomicSearchBox implements InitializableComponent { * 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; + @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. */ - @Prop() public suggestionTimeout = 400; + @property({type: Number, attribute: 'suggestion-timeout'}) + public suggestionTimeout = 400; /** * The delay for suggestion queries on input, in milliseconds. @@ -155,26 +168,40 @@ export class AtomicSearchBox implements InitializableComponent { * 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; + @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}. */ - @Prop({reflect: true}) public disableSearch = false; + @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`. */ - @Prop({reflect: true}) public minimumQueryLength = 0; + @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. */ - @Prop({reflect: true}) public clearFilters = true; + @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. @@ -184,75 +211,43 @@ export class AtomicSearchBox implements InitializableComponent { * * 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', + @property({ + type: Boolean, + attribute: 'enable-query-syntax', + reflect: true, + converter: booleanConverter, }) - 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 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 + ); + } + } + ); } - public initialize() { - this.id ||= randomID('atomic-search-box-'); - - this.initializeSearchboxController(); - this.initializeSuggestionManager(); + disconnectedCallback() { + super.disconnectedCallback(); } - private updateRedirectionUrl() { - if (this.isStandaloneSearchBox(this.searchBox) && this.redirectionUrl) { - this.searchBox.updateRedirectUrl(this.redirectionUrl); - } else { - this.registerNewSearchBoxController(); + willUpdate() { + if (!this.searchBoxState || !this.searchBox) { + return; } - } - - 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) @@ -274,43 +269,34 @@ export class AtomicSearchBox implements InitializableComponent { storage.setJSON(StorageItems.STANDALONE_SEARCH_BOX_DATA, data); this.searchBox.afterRedirection(); - const event = this.redirect.emit({redirectTo, value}); + const event = new CustomEvent('redirect', { + detail: {redirectTo, value}, + }); + this.dispatchEvent(event); 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 - ); - } - } + public initialize() { + this.id = randomID('atomic-search-box-'); - private registerSearchboxSuggestionEvents() { - this.searchBoxSuggestionEventsQueue.forEach((evt) => { - this.suggestionManager.registerSuggestionsFromEvent( - evt, - this.suggestionBindings - ); - }); - this.searchBoxSuggestionEventsQueue = []; + this.initializeSearchboxController(); + this.initializeSuggestionManager(); } - @Watch('redirectionUrl') - watchRedirectionUrl() { - this.updateRedirectionUrl(); + 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() { @@ -324,12 +310,58 @@ export class AtomicSearchBox implements InitializableComponent { getSearchBoxValue: () => this.searchBoxState.value, getSuggestionTimeout: () => this.suggestionTimeout, getSuggestionDelay: () => this.suggestionDelay, - getHost: () => this.host, + 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 > { @@ -401,12 +433,25 @@ export class AtomicSearchBox implements InitializableComponent { }; } - private updateBreakpoints = once(() => updateBreakpoints(this.host)); + 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(value)) { + if (this.isSearchDisabledForEndUser) { this.suggestionManager.clearSuggestions(); return; } @@ -419,6 +464,9 @@ export class AtomicSearchBox implements InitializableComponent { if (this.isExpanded) { return; } + if (this.isSearchDisabledForEndUser) { + return; + } this.isExpanded = true; await this.suggestionManager.triggerSuggestions(); this.announceNewSuggestionsToScreenReader(); @@ -435,7 +483,7 @@ export class AtomicSearchBox implements InitializableComponent { } private async onKeyDown(e: KeyboardEvent) { - if (this.isSearchDisabledForEndUser(this.searchBoxState.value)) { + if (this.isSearchDisabledForEndUser) { return; } @@ -488,282 +536,245 @@ export class AtomicSearchBox implements InitializableComponent { } private triggerTextAreaChange(value: string) { - this.textAreaRef.value = value; - this.textAreaRef.dispatchEvent(new window.Event('change')); + this.textAreaRef.value!.value = value; + this.textAreaRef.value!.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; + private announceNewActiveSuggestionToScreenReader() { + const ariaLabel = this.suggestionManager.activeDescendantElement?.ariaLabel; + if (isMacOS() && ariaLabel) { + this.suggestionsAriaMessage.message = ariaLabel; } - - 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; - } + private announceNewSuggestionsToScreenReader() { + const elsLength = + this.suggestionManager.allSuggestionElements.filter( + elementHasQuery + ).length; - return ( -
{ - if (e.target === getRef()) { - e.preventDefault(); + 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, } - }} - > - {elements.map((suggestion, index) => - this.renderSuggestion(suggestion, index, elements.length - 1, side) - )} -
- ); + ) + : this.bindings.i18n.t('query-suggestions-unavailable'); } - private get shouldShowSuggestions() { - return ( - this.suggestionManager.hasSuggestions && - this.isExpanded && - !this.isSearchDisabledForEndUser(this.searchBoxState.value) - ); + private announceClearSearchBoxToScreenReader() { + this.searchBoxAriaMessage.message = + this.bindings.i18n.t('search-box-cleared'); } - 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 renderAbsolutePositionSpacer() { + return html``; } - 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(); + 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(''); - }} - /> - ); - }; - - private renderAbsolutePositionSpacer() { - return ( - - ); + this.announceClearSearchBoxToScreenReader(); + }, + popup: { + id: `${this.id}-popup`, + activeDescendant: this.suggestionManager.activeDescendant, + expanded: this.isExpanded, + hasSuggestions: this.suggestionManager.hasSuggestions, + }, + }, + })}`; } - private isSearchDisabledForEndUser(queryValue?: string) { - if (isNullOrUndefined(queryValue)) { - return this.disableSearch; - } + private renderSuggestions() { + const part = `suggestions-wrapper ${ + this.suggestionManager.isDoubleList + ? 'suggestions-double-list' + : 'suggestions-single-list' + }`; - if (queryValue.trim().length < this.minimumQueryLength) { - return true; - } + const isVisible = + this.suggestionManager.hasSuggestions && + this.isExpanded && + !this.isSearchDisabledForEndUser; - return this.disableSearch; + 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 getSearchInputLabel(minimumQueryLength = 0) { - if (this.isSearchDisabledForEndUser(this.searchBoxState.value)) { - return this.bindings.i18n.t('search-disabled', { - length: minimumQueryLength, - }); + private renderPanel( + side: 'left' | 'right', + elements: SearchBoxSuggestionElement[], + setRef: (el: HTMLElement | undefined) => void, + getRef: () => HTMLElement | undefined + ) { + if (!elements.length) { + return null; } - 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'); + 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 announceNewActiveSuggestionToScreenReader() { - const ariaLabel = this.suggestionManager.activeDescendantElement?.ariaLabel; - if (isMacOS() && ariaLabel) { - this.suggestionsAriaMessage = ariaLabel; - } - } + private renderSuggestion( + item: SearchBoxSuggestionElement, + index: number, + lastIndex: number, + side: 'left' | 'right' + ) { + const id = `${this.id}-${side}-suggestion-${item.key}`; - private announceClearSearchBoxToScreenReader() { - this.suggestionsAriaMessage = this.bindings.i18n.t("search-box-cleared") - } + const isSelected = id === this.suggestionManager.activeDescendant; - 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, + if (index === lastIndex && item.hideIfLast) { + return null; + } + return html` + { + await this.suggestionManager.onSuggestionClick(item, e); + if (item.key === 'recent-query-clear') { + return; } - ) - : this.bindings.i18n.t('query-suggestions-unavailable'); + + this.isExpanded = false; + }} + .onMouseOver=${async () => { + await this.suggestionManager.onSuggestionMouseOver(item, side, id); + }} + > + `; } private renderSlotContent() { - const hasDefaultSlot = getDefaultSlotContent(this.host).length > 0; + const hasDefaultSlot = getDefaultSlotContent(this).length > 0; if (hasDefaultSlot) { - return ; + return html``; } - return [ - , - , - ]; + return html` + `; } - public render() { + @bindingGuard() + @errorGuard() + 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(), - ]} - - ); + 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/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'); } diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts index e52a59a233f..48d62c1e23f 100644 --- a/packages/atomic/src/components/search/index.ts +++ b/packages/atomic/src/components/search/index.ts @@ -28,6 +28,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 8ed23b8c65a..04a6bc9bb14 100644 --- a/packages/atomic/src/components/search/lazy-index.ts +++ b/packages/atomic/src/components/search/lazy-index.ts @@ -74,6 +74,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' 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', 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..4ff9253ae91 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/search-box-controller.ts @@ -0,0 +1,32 @@ +import type {SearchBox, SearchBoxState} from '@coveo/headless'; +import {vi} from 'vitest'; + +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, + 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; 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;