DEV Community

Cover image for Cross-framework web components library 📚 using Lit (Part I)
Fausto Braz
Fausto Braz

Posted on • Edited on

Cross-framework web components library 📚 using Lit (Part I)

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.

Project folder structure

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 {}

Enter fullscreen mode Exit fullscreen mode

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 {}

Enter fullscreen mode Exit fullscreen mode

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>
        `;
  }


}

Enter fullscreen mode Exit fullscreen mode

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];

    ...

}

Enter fullscreen mode Exit fullscreen mode

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();
    }
  }

}

Enter fullscreen mode Exit fullscreen mode

And now we should have a single card component that looks like this:

Web component card render

In the next part, we will test this component and do another that reuses the original card.

Top comments (2)

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Google Lit is a BaseClass

Worth to mention there are 60+ alternatives to Lit

webcomponents.dev/blog/all-the-way...

And if you bet on the wrong horse...

Building Web Components with Vanilla JavaScript may take 10% more time.

But you will built your own BaseClass (do peak at Lit's source code)

And have 100% control over your own code, instead of fearing breaking changes of Googles BaseClass on upgrade. (Remember when Google called Angular 2 an "upgrade", but you had to rewrite all your Angular 1 code?)

Collapse
 
fstbraz profile image
Fausto Braz

Well, that's a valid point of view.
Still, I don't share the concern or fear of upgrades, based on past experiences, since we can leverage many advantages in using already built tools, being the principal the simplicity to pick up and being proficient quickly in less time.
If we pick your example, I can say that in the last years, from version 2 to 14, Angular was one of the most robust and easy-to-upgrade frameworks.
Nothing is a silver bullet, but at the end of the day, we need to be able to trust others and ourselves to have an easier job and a smaller learning curve to include devs with less experience, and a well-documented lib helps a lot :)