HTML Web Components - Progressive Enhancement and CSS Encapsulation/Scope Made Easy!
JavaScript code sample from the <webui-form-validate> web component.

HTML Web Components - Progressive Enhancement and CSS Encapsulation/Scope Made Easy!

I have to thank Jeremy Keith and his wonderfully insightful article late last year for introducing me to the concept of HTML Web Components.

When you wrap some existing markup in a custom element and then apply some new behaviour with JavaScript, technically you’re not doing anything you couldn’t have done before with some DOM traversal and event handling. But it’s less fragile to do it with a web component. It’s portable. It obeys the single responsibility principle. It only does one thing but it does it well.

Until then, I'd been under the misapprehension that all web components relied solely on the presence of JavaScript, in conjunction with the rather scary-sounding shadow DOM.

And yes, while you can author web components in this way, there is another way. A better way, perhaps? Especially if, like me, you advocate for progressive enhancement.

Let's look at some examples, all of which can be found in my UI boilerplate's Storybook component library.

Example 1: <webui-disclosure> to show/hide content

See a working example of `<webui-disclosure>`.

The HTML markup is straightforward:

<webui-disclosure
    data-bind-escape-key
    data-bind-click-outside
>
    <button
        type="button"
        class="button button--text"
        data-trigger
        hidden
    >
        Show / Hide
    </button>

    <div data-content>
        <p>Content to be shown/hidden.</p>
    </div>
</webui-disclosure>        

If JavaScript is disabled, or doesn't execute for any number of other reasons, the button is hidden by the "hidden" attribute, and the content inside the "data-content" div is simply shown as normal. Nice, simple, progressive enhancement at work.

What about the JavaScript code to show/hide the content?

I prefer to use TypeScript when authoring vanilla JavaScript, to help eliminate stupid errors, and to enforce some degree of "defensive programming". Here's the complete webui-disclosure TypeScript code on Github.

However, for the sake of simplicity, the structure of the web component's ES6 class looks like this in plain JavaScript:

export default class WebUIDisclosure extends HTMLElement {
    constructor() {
        super();

        this.trigger = this.querySelector('[data-trigger]');
        this.content = this.querySelector('[data-content]');
        this.bindEscapeKey = this.hasAttribute('data-bind-escape-key');
        this.bindClickOutside = this.hasAttribute('data-bind-click-outside');

        if (!this.trigger || !this.content) return;

        this.setupA11y();

        this.trigger?.addEventListener('click', this);
    }

    setupA11y() {
        // Add ARIA props/state to button.
    }

    // Handle constructor() event listeners.
    handleEvent(e) {
        // Toggle visibility of content. Toggle ARIA state on button.
    }

    // Handle event listeners which are not part of this web component.
    connectedCallback() {
        document.addEventListener('keyup', (e) => {
            // Handle ESC key.
        });
        document.addEventListener('click', (e) => {
            // Handle clicking outside.
        });
    }

    disconnectedCallback() {
        // Remove event listeners.
    }
}         

Chris Ferdinandi ?? does an excellent job of explaining how to build a web component from scratch.

I've extended his ideas to enable hiding the content with the ESC key, and by clicking outside the web component. This is what the 2 "data-bind-" attributes are for in the HTML code further up this article.

You may be wondering about the event listeners?

The 1st one is defined in the "constructor()" function. The others are in the "connectedCallback()" function. This article should explain the rationale for this far more eloquently than me.

Accessibility considerations

The JavaScript adds appropriate "aria-expanded" and "aria-controls"" to the button, enabling screen reader users to understand the purpose of the button.

What about CSS encapsulation/scope?

For simplicity, I have not written any additional CSS for this web component. The styling you see is simply inherited from existing global or component styles (e.g. typography and buttons).

However, the next example does have some extra scoped CSS.

Example 2: <webui-tabs>

See a working example of `<webui-tabs>`.

The HTML markup is once again pretty straightforward:

<webui-tabs>
    <div data-tablist>
        <a href="#tab1" data-tab>
            Tab 1
        </a>
        <a href="#tab2" data-tab>
            Tab 2
        </a>
        <a href="#tab3" data-tab>
            Tab 3
        </a>
        <a href="#tab4" data-tab>
            Tab 4
        </a>
    </div>
    <div id="tab1" data-tabpanel>
        <p>1 - Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
    </div>
    <div id="tab2" data-tabpanel>
        <p>2 - Lorem ipsum dolor sit amet consectetur, adipisicing elit. </p>
    </div>
    <div id="tab3" data-tabpanel>
        <p>3 - Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
    </div>
    <div id="tab4" data-tabpanel>
        <p>4 - Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
    </div>
</webui-tabs>        

JavaScript code

Here's the complete webui-tabs TypeScript code on Github.

Here's the simplified structure:

export default class WebUITabs extends HTMLElement {
    constructor() {
        super();

        this.tablist = this.querySelector('[data-tablist]');
        this.tabpanels = this.querySelectorAll('[data-tabpanel]');
        this.tabTriggers = this.querySelectorAll('[data-tab]');

        if (
            !this.tablist ||
            this.tabpanels.length === 0 ||
            this.tabTriggers.length === 0
        )
            return;

        this.createTabs();

        this.tabTriggers.forEach((tabTrigger, index) => {
            tabTrigger.addEventListener('click', (e) => {
                this.bindClickEvent(e);
            });
            tabTrigger.addEventListener('keydown', (e) => {
                this.bindKeyboardEvent(e, index);
            });
        });
    }

    createTabs() {
        // Hide all tabpanels initially.
        // Add ARIA props/state to tabs & tabpanels.
    }

    bindClickEvent(e) {
        e.preventDefault();
        // Show clicked tab. Update ARIA props/state.
    }

    bindKeyboardEvent(e, index) {
        e.preventDefault();
        // Handle keyboard ARROW/HOME/END keys.
    }
}        

This time, all event listeners are in the "constructor()" function.

Accessibility considerations

The JavaScript adds:

  • Appropriate ARIA roles/states/props to the tabs and content blocks for screen reader users.
  • Extra keyboard bindings so the tabs can be operated via the ARROW/HOME/END keys.
  • The TAB key is reserved simply for accessing the web component's active tab, and any focusable content inside the currently displayed tabpanel.

What about CSS encapsulation/scope?

Here's the complete webui-tabs CSS/Sass code on Github.

Here's the simplified structure:

webui-tabs {
    [data-tablist] {
        // Default styles without JavaScript.
    }

    [data-tab] {
        // Default styles without JavaScript.
    }

    // Roles are added by JavaScript.
    [role='tablist'] {
        // Styles.
    }

    [role='tab'] {
        // Styles.
    }

    [role='tabpanel'] {
        // Styles.
    }
}        

The default styles simply arrange the anchors vertically with Flexbox.

With JavaScript, the styles are attached to the ARIA roles instead. This provides a good visual test to ensure that the correct ARIA has been generated.

These styles are fully encapsulated/scoped to the <webui-tabs> web component. There is no "leakage" to other components, or the global scope.

You'll also notice that I choose not to add a CSS classname to the web component, or use the BEM methodology for its children. The encapsulation afforded by the component's name is sufficient to allow the use of descendent selectors for the children.

Example 3: <webui-ajax-loader>

This final example differs from the previous 2 in that it is entirely generated by JavaScript, and does use the shadow DOM. This is because it's only used to indicate a "loading" state for Ajax requests, and is therefore only needed when JavaScript is enabled.

See a working example of `<webui-ajax-loader>`.

The HTML markup is simply:

<webui-ajax-loader></webui-ajax-loader>        

JavaScript code

Here's the complete webui-ajax-loader TypeScript code on Github.

Here's the simplified structure:

export default class WebUIAjaxLoader extends HTMLElement {
    constructor() {
        super();

        const shadow = this.attachShadow({ mode: 'open' });

        shadow.innerHTML = `
            <svg
                role="img"
                part="svg"
                ...
            >
                <title>loading</title>
                ...
            </svg>
        `;
    }
}        

The shadow DOM (i.e. the SVG) is constructed using a template literal. That's it.

Notice the part="svg" attribute. That comes in handy for the CSS.

Accessibility considerations

The SVG is given an accessible name (i.e. "loading") for screen reader users.

What about CSS encapsulation/scope?

Here's the complete webui-ajax-loader CSS/Sass code on Github.

Here's the simplified structure:

webui-ajax-loader {
    // Styles.

    // Pseudo element represents SVG in shadow DOM with matching part attribute.
    &::part(svg) {
        // Styles.
    }
}        

It is absolutely possible to style shadow DOM components in regular CSS files.

Remember that "part" attribute from earlier?

It's used here, via its matching "::part" pseudo element, to style the SVG, which is in the shadow DOM.

Conclusion

I hope this quick dive into how I've structured and composed my HTML web components shows how straightforward it is to progressively enhance standard HTML for a bit more interactivity.

If you're already using ES6 classes, converting them to web component syntax is very straightforward.



Eugeniu Lefter

Front-end | Drupal Developer

10 个月

Great article. Thanks for sharing.

Great article. Thanks for sharing.

Mike Heaver

Design Leader | Design Systems | Design Strategy

10 个月

Ian Sibley any roles going in your team?

Timothy Rackham

Design & Accessibility Lead at RSPCA

10 个月

要查看或添加评论,请登录

社区洞察

其他会员也浏览了