Create Custom Components

In this article

Coveo Atomic components fulfill most use cases, follow best UX practices, and are visually customizable, but sometimes you may need more features, further visual customization or a different behavior altogether. In such cases, we recommend building your own custom components using the same toolchain we used, i.e., Stencil.

To facilitate your creation of custom components, we provide a starter project with the Coveo CLI ui:create:atomic command. This command generates the necessary files and configurations for you to start working instantly on your desired feature. It contains:

To learn more about creating custom components, see the dedicated tutorial on Coveo level up: Create a Custom Atomic Component.

Native Web Components

While we highly recommend creating custom components in a Stencil project, it’s also possible to create custom native web components outside of a Stencil project, as shown in the following examples.

Custom Component Example

The following code sample implements a custom dropdown facet component.

<!-- ... -->
<script type='module'>
 import {initializeBindings} from 'https://static.cloud.coveo.com/atomic/v1/index.esm.js'; 1
 import {buildFacet} from 'https://static.cloud.coveo.com/atomic/v1/headless/headless.esm.js'; 2

 class DropdownFacet extends HTMLElement {
   shadow;
   field;
   bindings;
   facetController;

   facetUnsubscribe = () => {}; 3
   i18nUnsubscribe = () => {};

   async connectedCallback() { 4
     try {
       this.shadow = this.attachShadow({mode: 'closed'});
       this.field = this.getAttribute('field');
       if (!this.field) {
         throw new Error('Missing "field" attribute');
       }

       this.bindings = await initializeBindings(this); 5

       this.facetController = buildFacet(this.bindings.engine, { 6
         options: {field: this.field},
       });

       this.facetUnsubscribe = this.facetController.subscribe(() => 7
         this.render()
       );

       const updateLanguage = () => this.render(); 8
       this.bindings.i18n.on('languageChanged', updateLanguage);
       this.i18nUnsubscribe = () =>
         this.bindings.i18n.off('languageChanged', updateLanguage);
     } catch (error) {
       console.error(error);
     }
   }

   disconnectedCallback() { 9
     this.facetUnsubscribe();
     this.i18nUnsubscribe();
   }

   get selectElement() {
     return this.shadow.querySelector('select');
   }

   renderOption(facetValue) {
     const facetValueCaption = this.bindings.i18n.t(facetValue.value, { 10
       ns: `caption-${this.field}`,
     });
     const selectedAttribute = facetValue.state === 'selected' ? 'selected' : '';
     const count = `(${facetValue.numberOfResults.toLocaleString(
       this.bindings.i18n.language
     )})`;

     return `<option value="${facetValue.value}" ${selectedAttribute}>${facetValueCaption} ${count}</option>`;
   }

   renderSelect() {
     const {state} = this.facetController;

     if (!state.values.length) {
       return '';
     }

     const options = state.values.map((facetValue) =>
       this.renderOption(facetValue)
     );
     options.unshift('<option value=""></option>');

     return `<label for="author-facet">${this.bindings.i18n.t( 11
       this.field
     )}: </label><select id="author-facet">${options}</select>`;
   }

   onChange() { 12
     const value = this.selectElement.value;
     if (!value) {
       this.facetController.deselectAll();
       return;
     }

     const facetValue = this.facetController.state.values.find(
       (facetValue) => value === facetValue.value
     );

     if (facetValue) {
       this.facetController.toggleSingleSelect(facetValue);
     }
   }

   render() {
     this.shadow.innerHTML = this.renderSelect(); 13
     if (this.selectElement) {
       this.selectElement.addEventListener('change', () => this.onChange());
     }
   }
 }

 window.customElements.define('dropdown-facet', DropdownFacet);
</script>
<!-- ... -->
<body>
 <atomic-search-interface>
   <!-- ... -->
   <dropdown-facet field="author"></dropdown-facet>
 </atomic-search-interface>
</body>
1 Function to use to retrieve the Atomic bindings from the parent atomic-search-interface. Useful to access the Headless engine in order to create controllers, dispatch actions, access the controller state, etc, as you’ll do below.
2 Import the buildFacet method to create your facet controller below.
3 When disconnecting components from the page, we recommend removing state change listeners as well by calling the unsubscribe methods.
4 We recommend initializing the bindings and the Headless controllers using the connectedCallback lifecycle method with async/await.
5 Wait for the Atomic bindings to be resolved.
6 Initialize the controller using the Atomic bindings to access the Headless engine of your interface.
7 Subscribe to controller state changes.
8 (Optional) This line and the following are only relevant if your component needs to rerender when the Atomic i18n language changes (see Localization). If your component doesn’t use any strings or doesn’t support multiple languages, ignore everything related to i18n.
9 Use the disconnectedCallback lifecycle method to unsubscribe controllers and possibly the i18n language change listener.
10 Leverage i18n to localize facet value captions.
11 Leverage i18n to localize the facet label.
12 Leverage the Headless controller to dispatch the right actions in order to modify the controller state.
13 Render by inserting the content directly inside the HTML.

Custom Result Template Component Example

In custom result template components, you can leverage the initializeBindings method, as in other components. In addition, you can also leverage an exported resultContext method that fetches the result from the parent component’s rendered atomic-result. This lets you leverage result item field information, as in the example below.

The following custom component example conditionally renders the author of a result, with a fallback to "Anonymous" in the event that no author value is available.

Notes

This example is meant only for educational purposes. In a real life scenario, we recommend using result-field-condition and atomic-result-text.

<script type='module'>
 import {resultContext} from 'https://static.cloud.coveo.com/atomic/v1/index.esm.js';

 class DocumentAuthor extends HTMLElement {
  shadow;
  result;
  initialized = false;

  async connectedCallback() {
    if (this.initialized) { 1
      return;
    }

    this.initialized = true;
    this.shadow = this.attachShadow({mode: 'closed'});

    try {
      this.result = await resultContext(this); 2
      this.render();
    } catch (error) {
      console.error(error);
    }
  }

  render() {
    this.shadow.innerHTML = `<p>Author: ${
      this.result.raw.author ?? 'Anonymous'
    }</p>`;
  }
 }

 window.customElements.define('document-author', DocumentAuthor);
</script>
<!-- ... -->
<body>
 <atomic-search-interface>
   <!-- ... -->
   <atomic-result-list>
     <atomic-result-template>
       <template>
          <atomic-field-condition>
            <document-author></document-author>
          </atomic-field-condition>
       </template>
     </atomic-result-template>
   </atomic-result-list>
 </atomic-search-interface>
</body>
1 We recommend setting an initialization flag when implementing custom result list components, to avoid multiple initializations.
2 Fetch the result object of the target result item.