Enhancing Page Builder Widgets With Custom Web Components in Kentico Xperience

One of the many great features of Kentico is being able to create custom widgets for use in page builder, and there are several ways to implement custom behavior in those widgets. One way that we at BizStream have found to enhance widget behavior on the client-side is through custom Web Components.

What Are Custom Web Components?

Web components are, in the simplest sense, custom HTML elements that are written and implemented in JavaScript. They are similar to components in Angular, React, Vue, etc., except they are built-in for all modern browsers. There is a wealth of information on web components on MDN, and I highly recommend reading their documentation.

Why Custom Web Components Are Helpful

There are a few reasons why you might want to use web components to enhance your widgets in Kentico.

First of all, there is no build step required. If you are used to using a framework like Angular and React, you know a lot goes into setting up the project. With web components, all you need to start is JavaScript code.

Another benefit is progressive enhancement. Web components, when implemented correctly, will allow adding functionality to a web page, but in such a way as to not break the functionality of the web page if JavaScript is disabled and will not hinder a page's accessibility.

Finally, web components give the developer a way to encapsulate functionality fully within the custom component, using the Shadow DOM, and allows a Kentico widget to easily pass options from the server-side to the client-side through attributes.

Creating a Basic Widget Enhanced With a Custom Component

To demonstrate all this, we will make a custom pager component which can be added to a listing widget.

A Listing Widget

Start by creating a listing widget. Creating new widgets in Kentico is well documented here, but here is a sample piece of code that could be used to get a listing of articles using a C# View Component:

using Company.WebProject.PageTypes;
using Kentico.PageBuilder.Web.Mvc;
using Kentico.Web.Mvc;
using Microsoft.AspNetCore.Mvc;

namespace Company.WebProject.ViewComponents
{

    public class ArticleListingWidgetViewComponent : ViewComponent
    {
        #region Fields
        private readonly IPageRetriever pageRetriever;
        #endregion

        public ArticleListingWidgetViewComponent( IPageRetriever pageRetriever )
            => this.pageRetriever = pageRetriever;

        public async Task<IViewComponentResult> InvokeAsync( ComponentViewModel<ArticleListingWidgetProperties> properties )
        {
            // NOTE: We're not using the widget properties here, but they are available.
            // The following example is from the Kentico documentation, but has been modified
            // to make it asynchronous. See the original example at the URL below:
            // <https://docs.xperience.io/custom-development/working-with-pages-in-the-api>

            // Retrieves pages of the 'Article' page type that are in the '/Articles/May' section of the content tree
            var articles = await pageRetriever.RetrieveAsync<Article>( documentQuery => documentQuery
                            .Path("/Articles/May", PathTypeEnum.Children));

            return View( articles );
        }

    }

}

Then in our view component markup, we could have something like this:

@model IEnumerable<Article>

<h1>Article Listing</h1>

<listing-pager per-page="10">

  @Html.DisplayFor(Model)

</listing-pager>

Note the <listing-pager> element. That is our custom component which we will create next
 

The Pager Component


To create the pager web component, first add a javascript file and make sure it is imported in the site. To start, we will declare a class for our component and implement the base element class, making sure to implement a constructor. We will also scaffold out the rest of the methods to be implemented later.

class ListingPager extends HTMLElement {
  //
  // Constructor/initialization
  //
  constructor() {
    super();
  }

  //
  // Attribute methods
  //
  get perPage() {
    // Query the element's 'per-page' attribute
  }

  //
  // "Lifecycle" methods
  //
  connectedCallback() {
    // For when the element is attached to the DOM
  }

  disconnectedCallback() {
    // For when the element is removed from the DOM
  }

  //
  // Event callback methods
  //
  onPageButtonClick(event) {
    // When a different page is selected
  }

  onSlotChange() {
    // When new children are added or removed for this element
  }

  //
  // Main utility methods
  //
  addPageButton(pageNumber) {
    // When we need to add a new page button
  }

  removePageButton(pageNumber) {
    // When we need to remove a page button
  }

  updatePageButtons() {
    // Main update method, called any time the page buttons need to be updated
  }
}

There are quite a few methods here, so let's get started! We will implement each method in turn. To start, we will implement the constructor.

constructor() {
  // We always call super first
  super();

  // Next, we will create some HTML nodes we will use in our component. Note
  // that some of the elements we keep in instance properties directly so we
  // can reference them later
  const listingEl = document.createElement('div'); // Main listing container
  this.pagesEl = document.createElement('div'); // For our page buttons

  // Create a slot element and append to the listing container
  this.slotEl = document.createElement('slot');
  listingEl.append(this.slotEl);

  // Attach the shadow DOM and child HTML nodes
  this.attachShadow({ mode: 'open' });
  this.shadowRoot.append(listingEl, this.pagesEl);

  // Make sure event methods are bound correctly.
  this.onSlotChange = this.onSlotChange.bind(this);
  this.onPageButtonClick = this.onPageButtonClick.bind(this);
}

Next, we will implement the method that will query the "Per Page" attribute from the markup. We are implementing this method as a class getter to simplify its usage in the rest of the code.

get perPage() {
  // Get the attribute value and parse it
  const attrValue = this.getAttribute('per-page');
  const result = parseInt(attrValue);

  // Validate the value, returning a default value if it is invalid
  return isNaN(result) ? 0 : result;
}

Next are the "lifecycle" methods for our custom element. First, the connectedCallback method will be used to set up events and anything else that needs initialization in our element.

connectedCallback() {
  this.currentPage = 0; // Set initial current page index
  this.slotEl.addEventListener('slotchange', this.onSlotChange);
} 

The disconnectedCallback method will be used for any and all cleanup we need to do for our element.

disconnectedCallback() {
  this.slotEl.removeEventListener('slotchange', this.onSlotChange)
}

Now, we will get into the meat of our component and implement the main events and functionality. The first event we will set is for when a selected page changes.

onPageButtonClick(event) {
  // Set current page by getting the 'data-page' attribute, which
  // we will set in another method, from the page button that was
  // clicked.
  const newPage = parseInt(event.target.getAttribute('data-page'));
  this.currentPage = newPage;

  // Update the listing
  this.updatePageContents();
}

Next comes the event for when content is added or removed to the slot within out custom element.

onSlotChange() {
  // Simply update the buttons and the visibility of the
  // slotted content based on the number of slotted items
  // and the current page.
  this.updatePageButtons();
  this.updatePageContents();
}

The following two methods are utility methods for adding or removing buttons for selecting the current page. In the addPageButton method we will manually set the IDs and the data-page attributes for each button and use these in other methods. Since we have control of each button and its functionality in our element, we can safely make some assumptions and implement our own desired way things should work and interact, but best practices should always be followed, and the inner workings of our element should always be documented, especially since we have so much control of the code.

addPageButton(pageNumber) {
  const pageButton = document.createElement('button');
  pageButton.setAttribute('id', `btn-page-${pageNumber}`);
  pageButton.setAttribute('data-page', (pageNumber - 1).toString());
  pageButton.innerText = pageNumber;
  pageButton.addEventListener('click', this.onPageButtonClick);

  this.pagesEl.appendChild(pageButton);
}

The removePageButton will query for the button corresponding to the method's input, clean it up and remove it from our button list.

removePageButton(pageNumber) {
  const pageButton = this.querySelector(`#btn-page-${pageNumber}`);
  pageButton.removeEventListener('click', this.onPageButtonClick);
  pageButton.remove();
}

There are only two methods left! They hold the code for updating the content and buttons in our element, so let us dive into each one.

The next method we will implement is for updating the buttons. As seen earlier this will be called when content is added or removed from the slot, including any content already inside the slot in existing markup. This method will validate and calculate the number of pages based on the desired amount of items per page and add or remove buttons from our element accordingly.

updatePageButtons() {
  // Validate the value from the 'per-page' attribute
  if (this.perPage <= 0) {
    return;
  }

  // Calculate the number of pages based on 'per-page' value
  // and the existing content in the slot
  const slotContent = this.slotEl.assignedElements();
  const numberOfPages = Math.ceil(slotContent.length / this.perPage);

  // Remove page buttons if there are more than needed
  while (this.pagesEl.children.length > numberOfPages) {
    this.removePageButton(this.pagesEl.children.length);
  }

  // Add page buttons if we need more
  while (this.pagesEl.children.length < numberOfPages) {
    this.addPageButton(this.pagesEl.children.length + 1);
  }
}

The final method will update the visibility of the slotted content based on the current page that has been selected.

updatePageContents() {
  // Get the current slot contents
  const slotContents = this.slotEl.assignedElements();

  // Calculate the starting and ending indexes of our slotted content
  // elements that should be visible. Note that the ending index is
  // exclusive, i.e. [start .. end)
  const currentPageStart = this.currentPage * this.perPage;
  const currentPageEnd = (this.currentPage + 1) * this.perPage;

  // Loop through each element, hiding or showing it accordingly using
  // the 'display' CSS style property
  for (let i = 0; i < slotContents.length; ++i) {
    const el = slotContents[i]; 

    if (i < currentPageStart || i >= currentPageEnd) {
      el.style.display = 'none';
    } else {
      el.style.display = '';
    }
  }
}

Our element is finally complete, but there is one final step to make the Web Component usable. We need to register our custom element so that the DOM knows what it is. Here is how we do that.

class ListingPager extends HTMLElement { /*...*/ }

// Here is where we will register our new custom element
customElements.define('listing-pager', ListingPager);

With that, our element has been defined and we can use it on any page where the above code is imported or defined. There is quite a bit of code here, but the main goal here is to demonstrate how a larger custom element might be defined and implemented. At this point, there are also some things that need to be improved upon. For instance, if per-page is larger than the number of children there will still always be one page button created even though a button is not needed for only one page of content. There is also no styling which can be added in a style element explicitly and attached to the shadowRoot. But, in general, for a small to average amount of items this pager should work great.
 

Some Interesting Applications

One final thing to highlight is that having a new slotted custom element opens up several other opportunities. Not only can this be used to enhance Page Builder widgets, but this can also be used to enhance Page Builder sections as well! Using the pager we have already defined, we can use it just as easily to add paging to an entire section like this:

<listing-pager per-page="10">

    @Html.Kentico().WidgetZone()

</listing-pager>

Using this same idea, we can define and implement tabbed content in a section:

@*
  Our model could hold information about a tab and the content can be added
  as child widgets
*@

@model IEnumerable<Tabs>

<tab-container>

    @foreach ( var tab in Model )
    {
        <tab-label slot="tab-labels" id="@tab.Id" for="@tab.Title">
            <span>@tab.Title</span>
        </tab-label>
    }

    @foreach ( var tab in Model.Tabs )
    {
        <tab-panel slot="tab-content" id="@tab.Title)">

                @Html.Kentico().WidgetZone()

        </tab-panel>
    }

</tab-container>

The full implementation of tabs in the above example is quite a bit more complex, and I might revisit this idea in a future post.

Summary

With the flexibility and functionality that Web Components provide, we wind up with a lot of options and power at our fingertips. I hope this post has sparked some interest in looking deeper into Web Components and how they can be used, not only in Kentico but anywhere on the web.

Click here to read more Kentico posts
Start a Project with Us
Photo of the author, Mike Webb

About the author

Mike started his programming career in high school on a TI83+ calculator. In college, he continued with C++. His desire to code came from playing video games and wanting to know how they worked and from his mother, who also worked in software. After getting the bug for coding and with the insatiable thirst for learning, he never looked back. Aside from programming, Mike loves hiking, fishing, listening to and playing music, being involved at church, and spending time with his family and friends.

View other posts by Mike

Subscribe to Updates

Stay up to date on what BizStream is doing and keep in the loop on the latest with Kentico.