Cross-framework web components library 📚 using Lit (Part I)
Fausto Braz
Posted on August 30, 2022
Web Components are a new suite of technologies that allow us to create custom elements and encapsulate their functionality away from the rest of the code, making them highly reusable and shareable in your web apps, perfect for developing highly custom design systems.
For this example, we will create two card components, porting from one of my favorite pattern libraries, Inclusive Components props to Heydon Pickering for the excellent book.
Using Lit
Creating, scaling, and maintaining web components with vanilla Javascript can rapidly become messy, so I've chosen to enhance the expressiveness by using lit that makes use of Typescript.
Anyhow it would be best if you did not forget to take a look at the vanilla JS implementations to understand how the Web Components implementation work under the hood.
You can find the lit documentation under this link and the original Html and Js code of the card element here.
So, let's get our hands dirty
I've started initiating an empty git repo at link.
Feel free to add your contribution, and improvements are always welcome.
I've added Typescript, Eslint, Jasmine, and Open Web Components libraries, to keep all along with the best code practices and to test the components after:
My folder structure will be a components folder that can have shared stuff.
In this case, we will share some styles between the two components.
Next, we start creating a new class decorator @customElement ('card-image')
in card.ts
that extends from LitElement
that inherits and encapsulates some methods to access the web components API:
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('card-image')
export class Card extends LitElement {}
After we can start to define an interface to pass the component properties, I've declared it in the same file, but feel free to create a separated one, if you think that will be reused:
...
export interface CardConfig {
altText: string;
ctaText: string;
image: string;
link: string;
text: string;
textDesc: string;
textDescLink: string;
title: string;
}
@customElement('card-image')
export class Card extends LitElement {}
It is time to define the configuration object and the Html that will be returned in the render method:
...
@customElement('card-image')
export class Card extends LitElement {
@property({ type: Object }) card!: CardConfig;
render() {
return html`
<li class="card">
<div class="img">
<img src="${this.card.image}" alt="${this.card.altText}" />
</div>
<div class="text">
<h2>
<a id="card-link" href="${this.card.link}" aria-describedby="desc-a-card"
>${this.card.title}</a
>
</h2>
<p>${this.card.text}</p>
<span class="cta" aria-hidden="true" id="desc-a-card">${this.card.ctaText}</span>
<small><a href="${this.card.textDescLink}">${this.card.textDesc}</a></small>
</div>
</li>
`;
}
}
Now we can define the styles; we also import a shared style, which is the font definition:
...
import { sharedStyles } from './shared/style';
const componentStyle = css`
h2 {
margin-bottom: 1rem;
}
.card + .card {
margin-top: 1.5rem;
}
@supports (display: grid) {
.card + .card {
margin-top: 0;
}
}
.card {
cursor: pointer;
border: 1px solid;
border-radius: 0.25rem;
display: flex;
flex-direction: column;
position: relative;
}
.card .text {
padding: 1rem;
flex: 1 0 auto;
display: flex;
flex-direction: column;
cursor: pointer;
}
.card p {
max-width: 60ch;
}
.card .img {
height: 6.5rem;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 calc(100% - 1rem));
}
.card .img img {
width: 100%;
height: 100%;
object-fit: cover;
filter: grayscale(100%);
}
.card a {
outline: none;
}
.card h2 a {
text-decoration: none;
}
.card h2 a:focus {
text-decoration: underline;
}
.card h2:focus-within ~ .cta {
box-shadow: 0 0 0 0.125rem;
}
.card:focus-within h2 a:focus {
text-decoration: none;
}
.card small {
display: block;
text-align: right;
}
.card small a {
position: relative;
text-decoration: none;
padding: 0.5rem 0;
}
.card small a:hover,
.card small a:focus {
text-decoration: underline;
}
.card .text > * + * {
margin-top: 0.75rem;
}
.card .text > :nth-last-child(3) {
margin-bottom: 0.75rem;
}
.card .text > :nth-last-child(2) {
margin-top: auto;
padding-top: 0.75rem;
}
.cta {
padding: 0.75rem;
border: 1px solid;
border-radius: 0.25rem;
text-align: center;
}
.cta > a {
text-decoration: none;
}
`;
@customElement('card-image')
export class Card extends LitElement {
static styles = [sharedStyles, componentStyle];
...
}
Finally, we will set up two events to solve the redundant click event problem listed here
...
@customElement('card-image')
export class Card extends LitElement {
...
@query('#card-link') cardLinkEl!: HTMLAnchorElement;
render() {
return html`
<li class="card">
<div class="img">
<img
src="${this.card.image}"
alt="${this.card.altText}"
@mousedown="${this.mouseDown}"
@mouseup="${this.handleClick}"
/>
</div>
<div class="text">
<h2>
<a
id="card-link"
href="${this.card.link}"
@mousedown="${this.mouseDown}"
@mouseup="${this.handleClick}"
aria-describedby="desc-a-card"
>${this.card.title}</a
>
</h2>
<p>${this.card.text}</p>
<span class="cta" aria-hidden="true" id="desc-a-card"
><a href="${this.card.link}">${this.card.ctaText}</a>
</span>
<small><a href="${this.card.textDescLink}">${this.card.textDesc}</a></small>
</div>
</li>
`;
}
mouseDown() {
this.down = Number(new Date());
}
handleClick() {
this.up = Number(new Date());
const total = this.up - this.down;
if (total < 200) {
this.cardLinkEl.click();
}
}
}
And now we should have a single card component that looks like this:
In the next part, we will test this component and do another that reuses the original card.
Posted on August 30, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.