Web components
WEB COMPONENTS AND CSS LIBRARIES: AN AWKWARD FIT
In my new job, I've been working a lot with an existing codebase that uses web components (via Lit) for the HTML and Patternfly for the CSS, and I've discovered that Patternfly, as well as other CSS Libraries such as Tailwind or Bootstrap, are awkward fits for developing web components. You usually end up importing too much per component or sacrificing code readability on the altar of code size.
Let me give you an example:
This is the code for a navigation bar associated with a wizard component:
render() {
html`
<nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
${this.steps.map((step, idx) => {
const currentIdx = this.currentStep
? this.steps.indexOf(this.currentStep.slot)
: 0;
return html`
<li class="pf-c-wizard__nav-item">
<button
class="pf-c-wizard__nav-link ${idx === currentIdx
? "pf-m-current"
: ""}"
?disabled=${currentIdx < idx}
@click=${() => {const stepEl = this.querySelector<WizardPage>(
`[slot=${step}]);
if (stepEl) {
this.currentStep = stepEl;
}
}}
>
${this.querySelector<WizardPage>(
`[slot=${step}]`,
)?.sidebarLabel()}
</button>
</li>
`;
})}
</ol>
</nav>`;
}
There's a lot going on here! Let me show you the cleaned-up version:
handleClick(ev: MouseEvent) {
const step: string | undefined = ev.target?.dataset.step;
this.dispatchCustomEvent("ak-wizard-navigate", { step });
}
renderItem([step, label]: Pair, idx: number) {
const highlightClasses = {
current: this.current === idx,
};
return html`
<li>
<button
class="${classMap(highlightClasses)}"
?disabled=${this.current < idx}
data-step=${step}
@click=${this.handleClick}
>
${label}
</button>
</li>
`;
}
render() {
return html`
<nav>
<ol>
${map(this.steps, this.renderItem)}
</ol>
</nav>
`;
}
The first thing you should see is that the loop and what the loop builds are in two separate locations; we can now see what an individual item is without having to visually process the clutter surrounding it.
Secondly, the "highlight" code is isolated into a single expression; you can see what it means without having to think about it.
But more than that, all the CSS is gone. Because this is a web component, we can safely address
the <nav>
, <li>
and <button>
elements directly because the CSS will be
contained inside the component's
ShadowDOM and
will not affect any of the HTML outside of the component.
This is what Web Components, HTML Templates, and the ShadowDOM all together are supposed to enable: Structure and Semantics are separated from Styling, and Business Logic of "what to do when a user selects a navigation item" is managed by the parent container component and a very simple event handler. In fact, Lit's lovely event handler syntax makes it as simple as:
<ak-wizard-navigation @ak-wizard-navigate={(ev) => this.navigateTo(ev.detail.step)}>
How excellent is that!?
The point of my refactor is simple: This is an ordered list of links in a sequence, and elements beyond a certain position in the sequence are unavailable*. When you click on a link, a custom event with some specific data for the event is sent to the containing HTML. That's all this is, but trying to discern that from first example requires a lot more mental note-taking, and my goal is to eliminate that.
The problem comes when you try to isolate Patternfly's CSS to only the CSS you're including in your component. ShadowDOMs are isolated, meaning that very little from the outside world gets in. One thing that does get through the ShadowDOM's barrier is CSS Custom Properties, which Patternfly uses to define its design system.
But looking at Patternfly's definition of, for one example, a button, you'll see that there's a mind-boggling variety of buttons in a single SASS file, none of which can be safely imported into your web component without importing all of them, and unfortunately, importing them all into the global space risks contaminating your global space with unwanted CSS.
The end result is that you're going to end up with CSS stylesheets approximately 180KB in size per component, and if you're like me and really like small, isolated units that are easy to think about, understand, and maintain, that's a lot of wasted memory to handle CSS you're never going to use.
I want to believe that the problem is that I don't understand Patternfly well enough; that if I did,
I could isolate the Custom Properties collection into the global space and just import the
definition of "a button" or "a link" or "a list", and it would be a small and reasonable thing to
think about. But I don't think that's true; I think I'm still trapped with unreasonably large web
components when importing a CSS library, especially one like Patternfly where a single <h1>
with some emphatic styling of type pf-c-title pf-m-3xl pf-c-wizard__title
pulls in three different
large components.
[Addendum] Elliot Marquez, a developer on the Lit-Element and Material Design team at Google, reached out and reassured me that I actually do understand the problem quite well:
This is something we ran into migrating legacy Material Design CSS to web components.
A lot of CSS out there is not modular and often crosses component boundaries. I think this is what makes Tailwind an innovative project where they are bridging HTML + CSS with compilation. Since then we have rewritten Material Design CSS to be modular and shadow DOM ready.
Nice to know I'm not the only one with this headache.