DEV Community

loading...

How to make a tab control with Web Components

ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
Updated on ・8 min read

We're going to be building a tab control with vanilla JS and no build step. This is part of a series with the previous edition building a resizable panel. As such I'll probably skip over a little more of the boilerplate.

Tab controls have all sorts of variations but here's the requirements for what we'll be building:

  • We want to have a row of tabs with labels.
  • Each tab has associated content.
  • We want to click a tab to show the content and hide the rest.
  • The content should be arbitrary content.

What we are not doing:

  • Changing routes on tab click (hash routes to static content are trivial to add but server coordination might take a bit of work)
  • Configurable tabs like those seen in an IDE or a browser where you can add, re-arrange, remove etc.

Boilerplate

I won't explain it again this time but I'm using the same boilerplate as with the split-panel control.

export class WcTabPanel extends HTMLElement {
  static observedAttributes = [];
  constructor(){
    super();
    this.bind(this);
  }
  bind(element){
    element.render = element.render.bind(element);
    element.cacheDom = element.cacheDom.bind(element);
    element.attachEvents = element.attachEvents.bind(element);
  }
  connectedCallback(){
    this.render();
    this.cacheDom();
    this.attachEvents();
  }
  render(){
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = ``;
  }
  cacheDom(){
    this.dom = {
    };
  }
  attachEvents(){

  }
  attributeChangedCallback(name, oldValue, newValue){
    if(oldValue !== newValue){
      this[name] = newValue;
    }
  }
}

customElements.define("wc-tab-panel", WcTabPanel);
Enter fullscreen mode Exit fullscreen mode

Please note that all new methods should be added to the bind method. For brevity, that will be omitted in the snippets.

Adding Content

There's a lot of different types of APIs you could design for adding content.

One common way used by several systems I've seen is to have an outer "container" element (we'll call tab-panel-strip but then nested inside have other custom elements for the actual tabs (say tab-label) and actual panels (maybe tab-panel). Something like:

<tab-panel-strip>
  <tab-label>Tab A<tab-label>
  <tab-label>Tab B<tab-label>
  <tab-label>Tab C<tab-label>
  <tab-panel>Content A</tab-panel>
  <tab-panel>Content B</tab-panel>
  <tab-panel>Content C</tab-panel>
</tab-panel-strip>
Enter fullscreen mode Exit fullscreen mode

I dislike this approach. My general advice when making custom components is to avoid systems of components. It's hard to remember what is allowed to be nested in what. Also since these are custom elements people will forget what you called them, so more means more trips to documentation. Plus, you either won't validate the element trees and so clients will inevitably nest bad things that won't work, or even worse, it will somewhat work leading to confusion and bugs. Even if you did validation, this is largely a waste code bytes and cycles. Rather, just keep it simple. Try to stick to the things that already exist.

Here's my recommendation: Have the single custom element and let users nest whatever element they want between two slots, one for tabs and one for content:

<wc-tab-panel>
   <h1 slot="tab">Tab A</h1>
   <p slot="content">Content A</p>
   <h1 slot="tab">Tab B</h1>
   <p slot="content">Content A</p>
   <h1 slot="tab">Tab C</h1>
   <p slot="content">Content A</p>
</wc-tab-panel>
Enter fullscreen mode Exit fullscreen mode

This keeps it simple but also make it easier if the user just wanted to insert a <video> as content or something. No nesting required!

The other cool part about not getting too picky about content is that we can fallback more gracefully. If the user's browser didn't support custom elements or didn't support javascript we can fallback to a useful state. Say each tab is an h1 and each content is p. Provided the user interleaved them correctly (like above) they would display nicely and be perfectly accessible even in a failure case.

The shadow DOM

Let's setup the shadow DOM.

render() {
    this.shadow = this.attachShadow({ mode: "open" });
    this.shadow.innerHTML = `
            <style>
                :host { display: flex; flex-direction: column; }
                .tabs { display: flex; flex-direction: row; flex-wrap: nowrap; }
            </style>
            <div class="tabs">
                <slot id="tab-slot" name="tab"></slot>
            </div>
            <div class="tab-contents">
                <slot id="content-slot" name="content"></slot>
            </div>
        `;
}
Enter fullscreen mode Exit fullscreen mode

This is pretty straightforward. We have a horizontal row for the tabs and a container for the tab-contents. You can use CSS Grid here as well as Grid can mostly handle anything flexbox can, I'm just keeping it simple for now.

Next we'll start picking out the elements we need access to.

cacheDom() {
    this.dom = {
        tabSlot: this.shadow.querySelector("#tab-slot"),
        contentSlot: this.shadow.querySelector("#content-slot")
    }
    this.dom.tabs = this.dom.tabSlot.assignedElements();
    this.dom.contents = this.dom.contentSlot.assignedElements();
}
attachEvents() {
Enter fullscreen mode Exit fullscreen mode

The first part probably makes sense, we need access to the slots where we put the tabs, but what about the two lines after? The whole switching mechanism is based on the index of the tabs so we need to get the list of tabs. However, querying the slot's children will not find them because they are technically children of the element itself. So in order to get access to them we query the assignedElements() of the slots (you might have see assignedElements has a boolean parameter that lets it grab children nested slot elements as well, but we don't need that). Note that there is also an assignedNodes() method for slots as well. assignedNodes() will grab all nodes, including text and comment nodes which we don't want to consider so we use assignedElements().

It should be pointed out here that we're technically precomputing this list of tabs and contents. That is, we're only considering only tabs and contents that existed prior to the element being initialized. If a tab is inserted later we'll need to update these node lists.

Attributes

When I made this control I made it with a single attribute called selected-index which controls which tab is shown based on the index. As I've reflected on this further, I think that the shown tab could actually be considered "visual state" and per recommendations might be a CSS custom property but I can't think of a way to change the tab with just a custom property, especially since there is no way to observe them or use them to toggle states. Trying to figure out if it actually makes sense and if it actually works is still one of the harder parts to making custom elements.

We'll also have a property for direction, which will work near identical to the attribute on split panel, it changes from "row" to "column" depending on whether the tabs are vertical or horizontal. Again, this is definitely visual state (imagine wanting to change tab orientation in response to a media-query) but we're stuck with attributes for now.

So first let's add our observed attributes:

static observedAttributes = ["selected-index", "direction"];
Enter fullscreen mode Exit fullscreen mode

And then some boilerplate for translating the attribute into a property (this are all on the class and will be ordered differently in the final product, I'm just compressing for the sake of explanation):

#selectedIndex = 0;
#direction = "row";
attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue !== newValue) {
        if(name === "selected-index"){
            this.selectedIndex = newValue;
        } else {
            this[name] = newValue;
        }
    }
}
set selectedIndex(value) {
    this.#selectedIndex = value;
}
get selectedIndex() {
    return this.#selectedIndex;
}
Enter fullscreen mode Exit fullscreen mode

As we did with the split-panel control I'm creating getters and setters with a private backing field so that you can programmatically set and get properties with JS and we can change visual state or whatever else in response to those. For the attributeChangedCallback I'm using an if statement and this is because it deals with a new problem we didn't have with the split-panel. In HTML-land attributes with multiple words are hyphen-cased because HTML is not be case-sensitive. So when converting it over we have two choices:

1) Keep the same style but now all references must be made using index syntax e.g. ["selected-index"]
2) Transform the attribute name into camelCase, e.g "selectedIndex"

The second is definitely preferred by convention and because it makes the code less annoying to write but I have used the the former when lazily building some components. In more complex scenarios I'd make a helper function that all attribute names are run through when setting properties on the class but since it's just the one, we'll skip that complexity and manually set it.

Events

The most important internal event is when a tab is clicked. We want to update the content shown. We start by registering a click event if the user clicks anywhere on the tab slot:

attachEvents() {
    this.dom.tabSlot.addEventListener("click", this.onTabClick);
}
Enter fullscreen mode Exit fullscreen mode

Then we set the handler:

onTabClick(e) {
    const target = e.target;
    if (target.slot === "tab") {
        const tabIndex = this.dom.tabs.indexOf(target);
        this.selectTabByIndex(tabIndex);
    }
}
Enter fullscreen mode Exit fullscreen mode

This gets a reference to the element that was clicked, makes sure it was registered to the slot (technically you could have a child in the slot that's not slotted, though I don't think that would ever be a good idea), figures out which index it sits at (minus text and comment nodes as explained before) and then calls a method to select that index.

selectTabByIndex(index) {
    const tab = this.dom.tabs[index];
    const content = this.dom.contents[index];
    if (!tab || !content) return;
    this.dom.contents.forEach(p => p.classList.remove("selected"));
    this.dom.tabs.forEach(p => p.classList.remove("selected"));
    content.classList.add("selected");
    tab.classList.add("selected");
}
Enter fullscreen mode Exit fullscreen mode

Here we get a reference to the tab and corresponding content. We do a quick sanity check to make sure there is a corresponding content and tab, if not then we just abort. Then we remove the selected class from all tabs and contents to reset the styles and then add it on the ones we just selected.

We could potentially raise this event outside of the component if we wanted, but I have chosen not to, in order to conserve bytes until I have a use-case for it.

A less obvious event we also need to be aware of is when the slotted content changes:

attachEvents() {
    this.dom.tabSlot.addEventListener("click", this.onTabClick);
    this.dom.tabSlot.addEventListener("slotchange", this.onTabSlotChange);
    this.dom.contentSlot.addEventListener("slotchange", this.onContentSlotChange);
}
onTabSlotChange(){
    this.dom.tabs = this.dom.tabSlot.assignedElements();
}
onContentSlotChange(){
    this.dom.contents = this.dom.contentSlot.assignedElements();
}
Enter fullscreen mode Exit fullscreen mode

The slotchange event is specific to slots and gets triggered when items are added or removed. When this happens we want to update our internal lists of tabs and contents to make sure they are up-to-date or tabs added after component initialization won't work correctly.

Note that there is a trade-off here. We could just use {slot}.assignedElements() instead of caching the lists and updating them. I've opted not to as this would mean we iterate elements every time a tab is clicked rather than when the tabs are actually updated. A little more code for a little savings in CPU. Realistically, you probably would never have enough tabs that it would matter much one way or the other, but it's at least a nice demonstration for the slotchange event.

Styles

Back up in the render method, we can add additional styles in the shadow DOM:

:host([direction="column"]) { flex-direction: row; }
:host([direction="column"]) .tabs { flex-direction: column; }
.tabs ::slotted(*) { padding: 5px; border: 1px solid #ccc; user-select: none; cursor: pointer; }
.tabs ::slotted(.selected) { background: #efefef; }
.tab-contents ::slotted(*) { display: none; }
.tab-contents ::slotted(.selected) { display: block; padding: 5px; }
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here, the first two lines deal with the vertical tab case. The rest is mostly trying to visually differentiate the tabs and contents by giving the tabs a border, a button cursor and darkening the selected one. For the contents we're hiding them all and only showing the selected one.

We don't really need to need to add too many style hooks here because the user can pass in whatever the want (a benefit from not creating a custom element for "tabs") and easily override anything they don't like.

I added one little control that will make life easier, setting the gap between tabs with --tab-gap:

.tabs { display: flex; flex-flow: row nowrap; gap: var(--tab-gap, 0px); }
Enter fullscreen mode Exit fullscreen mode

This uses the new gap property for flexbox which lets us control the spacing on the container so the user doesn't need to worry about margin-rights and such.

Demo

Here's a demo of the completed component. Try seeing what happens when the JS fails or doesn't download. We should still get a reasonable result. You can also change orientation and add content to demonstrate it works.

Discussion (2)

Collapse
dannyengelman profile image
Danny Engelman

You are using syntax not supported in FireFox (and probably Safari not either)

Collapse
ndesmic profile image
ndesmic Author

Ah, sorry, private fields are still behind a flag in Firefox, I'm not sure about Safari (don't have access to mac) but MDN indicates probably not either. Some simple solutions you can use if you need to extend support:

  • If you want to keep the purity of no-build, replace the # on the private fields with _. Strictly enforcing the privacy of the fields isn't important here so we can use public ones instead as long as the names don't conflict with the setters.
  • You can use a transpiler.