Skip to content

Introductie tot LIT components ​

Inleiding ​

LIT (Lightweight, Intuitive, Typed) components vormen een moderne benadering voor het bouwen van webcomponenten die efficiënt, performant en eenvoudig te onderhouden zijn. Als onderdeel van de Web Components standaard biedt LIT een gestroomlijnde manier om herbruikbare UI-elementen te creëren die naadloos werken in elke webomgeving, onafhankelijk van het gebruikte framework. In deze gids verkennen we wat LIT components zijn, waarom ze waardevol zijn voor onze ontwikkelingsworkflow, en hoe je het LIT ecosysteem kunt benutten voor je projecten binnen onze monorepo-structuur.

Wat zijn LIT components? ​

LIT is een lichtgewicht bibliotheek voor het bouwen van webcomponenten met een focus op eenvoud, prestaties en typeervaring. LIT components zijn in essentie webcomponenten die gebruikmaken van de LIT bibliotheek om de ontwikkeling te vereenvoudigen en te verbeteren.

Kernprincipes van LIT ​

  1. Gebaseerd op webstandaarden: LIT bouwt voort op de Web Components standaard, inclusief Custom Elements, Shadow DOM en HTML Templates.

  2. Declaratieve templating: LIT gebruikt een intuïtieve, HTML-achtige templating taal met JavaScript template literals.

  3. Reactieve eigenschappen: LIT biedt een eenvoudig maar krachtig systeem voor reactieve eigenschappen dat efficiënte updates mogelijk maakt.

  4. Kleine footprint: De bibliotheek is ontworpen om minimaal te zijn, met een focus op prestaties en een kleine bundel-grootte.

  5. TypeScript-first: LIT biedt uitstekende TypeScript-ondersteuning voor type-veilige componenten.

Voorbeeld van een eenvoudige LIT component ​

typescript
// counter-element.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './counter-element.scss';

@customElement('counter-element')
class CounterElement extends LitElement {
  static styles = styles;

  @property({ type: Number })
  count = 0;

  render() {
    return html`
      <div class="counter">
        <p>Huidige telling: ${this.count}</p>
        <button @click=${this.increment}>Verhoog</button>
        <button @click=${this.decrement}>Verlaag</button>
      </div>
    `;
  }

  private increment() {
    this.count++;
  }

  private decrement() {
    this.count--;
  }
}
scss
// counter-element.scss
@use '@diekeure/cds-tokens' as cds;

.counter {
  border: 1px solid rgb(cds.get('cds.sys.color.outline'));
  padding: cds.get('cds.sys.spacing.s');
  border-radius: cds.get('cds.sys.shape.medium'));

  p {
    color: rgb(cds.get('cds.sys.color.on-surface'));
    margin-bottom: cds.get('cds.sys.spacing.xs');
  }

  button {
    margin-right: cds.get('cds.sys.spacing.xs');
    padding: cds.get('cds.sys.spacing.2xs') cds.get('cds.sys.spacing.xs');
    background-color: rgb(cds.get('cds.sys.color.primary'));
    color: rgb(cds.get('cds.sys.color.on-primary'));
    border: none;
    border-radius: cds.get('cds.sys.shape.small'));
    cursor: pointer;

    &:hover {
      opacity: 0.9;
    }
  }
}

Waarom LIT components gebruiken naast onze Angular apps? ​

In onze organisatie gebruiken we de LIT dcr-components repository om framework-agnostische lightweight core components te maken die in verschillende omgevingen kunnen worden gebruikt. Deze aanpak biedt verschillende voordelen:

1. Framework-onafhankelijkheid ​

LIT components zijn webcomponenten die werken in elke omgeving, wat cruciaal is voor onze diverse technische landschap:

  • Gebruik in Angular applicaties: Onze core components kunnen naadloos worden geïntegreerd in onze Angular applicaties.
  • Injecteerbaar in Rich Text Editors: De componenten kunnen worden gebruikt binnen RTE's zonder compatibiliteitsproblemen.
  • Learnosity Custom Questions: Dezelfde componenten kunnen worden gebruikt in Learnosity-omgevingen.

2. Prestaties ​

LIT is ontworpen met prestaties als kernprincipe:

  • Kleine bundel-grootte: De LIT runtime is slechts ~5KB (geminificeerd en gecomprimeerd), wat belangrijk is voor injecteerbare componenten.
  • Efficiënte updates: LIT's reactieve systeem werkt alleen DOM-elementen bij die daadwerkelijk veranderen.
  • Lazy loading: Componenten kunnen eenvoudig lazy loaded worden voor betere initiële laadtijden.

3. Encapsulatie via Shadow DOM ​

LIT maakt gebruik van Shadow DOM voor sterke encapsulatie:

  • CSS-isolatie: Stijlen blijven binnen de component en beïnvloeden de rest van de pagina niet, wat cruciaal is bij injectie in externe omgevingen.
  • DOM-isolatie: De interne DOM-structuur is afgeschermd van externe manipulatie.
  • Duidelijke grenzen: Creëert een heldere scheiding tussen component en applicatie.

4. Ontwikkelervriendelijkheid ​

LIT biedt een intuïtieve ontwikkelervaring:

  • Declaratieve templates: HTML-achtige syntax die vertrouwd aanvoelt.
  • TypeScript-integratie: Uitstekende type-ondersteuning voor veiligere code.
  • Eenvoudige API: Minimale leerdrempel met een beknopte, expressieve API.
  • Uitstekende DevTools-ondersteuning: Werkt goed met standaard browser DevTools.

5. Duurzaamheid en standaardisatie ​

  • Gebaseerd op webstandaarden: Bouwt voort op gestandaardiseerde technologieën.
  • Toekomstbestendig: Minder risico op veroudering omdat het gebaseerd is op platformstandaarden.
  • Brede ondersteuning: Werkt in alle moderne browsers.

Vergelijking met Angular ​

Hoewel we Angular gebruiken voor onze hoofdapplicaties, biedt LIT specifieke voordelen voor onze core components:

AspectLITAngular
ArchitectuurWebcomponenten met Shadow DOMComponent-gebaseerd framework
Bundel-grootte~5KB (core)~150KB+ (core)
ComponentmodelNatieve webcomponentenAngular-specifieke componenten
InjecteerbaarheidUitstekend (weinig dependencies)Uitdagend (veel dependencies)
ToolingMinimaal, flexibelUitgebreid, prescriptief
TypeScript-integratieGoedUitstekend
Learning curveRelatief vlakSteil

Waarom LIT voor core components:

  • Voor kleinere, injecteerbare componenten
  • Voor veel kleinere bundel-groottes
  • Voor betere framework-onafhankelijkheid
  • Voor gebruik in diverse omgevingen (RTE's, Learnosity)

LIT ecosysteem en tooling in onze workflow ​

Core bibliotheek ​

De LIT core bibliotheek bestaat uit verschillende packages:

  1. lit: Het hoofdpackage dat alles bundelt wat je nodig hebt.
  2. lit-element: De basisklasse voor het maken van LIT components.
  3. lit-html: De templating engine die LIT gebruikt.

Onze ontwikkeltools ​

1. NX Monorepo ​

We gebruiken NX voor het beheren van onze monorepo waarin dcr-core een onderdeel is:

  • Gedeelde configuratie: Consistente tooling en configuratie over projecten heen
  • Efficiënte builds: Incrementele builds en caching
  • Dependency management: Eenvoudig beheer van interne dependencies
bash
# Voorbeeld van een commando om een nieuwe LIT component te genereren in onze NX monorepo
nx generate @nxext/lit:component counter-element --project=dcr-components

2. Vite als build tool ​

We gebruiken Vite voor snelle ontwikkeling en efficiënte bundeling:

  • Snelle HMR: Bijna instantane hot module replacement
  • ES modules: Efficiënte ontwikkelervaring met native ES modules
  • Optimalisatie: Uitstekende productie-optimalisatie
javascript
// vite.config.ts voorbeeld voor een LIT component library
import { defineConfig } from 'vite';
import { resolve } from 'path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      formats: ['es'],
    },
    rollupOptions: {
      external: /^lit/,
    },
  },
});

3. Storybook ​

Storybook speelt een centrale rol in onze workflow:

  • Ontwikkeling: Playground voor het ontwikkelen en testen van componenten in isolatie
  • Documentatie: Levende documentatie van onze componentenbibliotheek
  • Visuele regressietests: Automatische visuele tests om regressies te detecteren
  • Toegankelijkheidstests: A11y tests om toegankelijkheidsproblemen te identificeren
  • Interactieve tests: Tests voor gebruikersinteracties
typescript
// .storybook/main.js voorbeeld voor LIT components
const config: StorybookConfig = {
  stories: ['../src/lib/readme.mdx', '../src/lib/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],

  addons: [
    getAbsolutePath('@storybook/addon-essentials'),
    getAbsolutePath('@storybook/addon-interactions'),
    getAbsolutePath('@storybook/addon-a11y'),
    getAbsolutePath('@storybook/addon-themes'),
    getAbsolutePath('@storybook/addon-interactions'),
    getAbsolutePath('storybook-addon-tag-badges'),
    getAbsolutePath('@chromatic-com/storybook'),
    getAbsolutePath('@storybook/addon-mdx-gfm'),
  ],

  framework: {
    name: getAbsolutePath('@storybook/web-components-vite'),
    options: {
      builder: {
        viteConfigPath: 'vite.config.ts',
      },
    },
  },

  staticDirs: ['../public'],

  docs: {},
};

export default config;

4. Testing ​

Voor het testen van onze LIT components gebruiken we:

  • Web Test Runner: Voor unit tests
  • Storybook Tests: Voor visuele en interactieve tests
  • Axe-core: Voor toegankelijkheidstests
bash
# Voorbeeld van een test commando in onze NX configuratie
npx nx test dcr-components

Best practices voor LIT components in onze workflow ​

1. Component structuur ​

typescript
// user-profile.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './user-profile.scss';

@customElement('user-profile')
export class UserProfile extends LitElement {
  static override styles = styles;

  // Publieke API - properties
  @property({ type: String })
  public username = '';

  @property({ type: String })
  public avatarUrl = '';

  // Interne state
  private _isExpanded = false;

  // Lifecycle methodes
  connectedCallback() {
    super.connectedCallback();
    // Setup code hier
  }

  // Rendering
  override render() {
    return html`
      <div class="profile ${this._isExpanded ? 'expanded' : ''}">
        <img src=${this.avatarUrl} alt=${this.username} />
        <div class="info">
          <h3>${this.username}</h3>
          <button @click=${this._toggleExpand}>${this._isExpanded ? 'Minder' : 'Meer'} info</button>
          ${this._isExpanded ? this._renderDetails() : ''}
        </div>
      </div>
    `;
  }

  // Helper render methodes
  private _renderDetails() {
    return html`
      <div class="details">
        <slot></slot>
      </div>
    `;
  }

  // Event handlers
  private _toggleExpand() {
    this._isExpanded = !this._isExpanded;
    this.dispatchEvent(
      new CustomEvent('expand-change', {
        detail: { expanded: this._isExpanded },
      })
    );
  }
}

2. Styling best practices ​

scss
// user-profile.scss
@use '@diekeure/cds-tokens' as cds;

:host {
  // Component container styling
  display: block;

  // CSS Custom Properties voor theming
  --dcr-comp-user-profile-bg: cds.get('cds.sys.color.surface');
  --dcr-comp-user-profile-text: cds.get('cds.sys.color.on-surface');
  --dcr-comp-user-profile-accent: cds.get('cds.sys.color.primary');
}

.profile {
  background-color: rgb(var(--dcr-comp-user-profile-bg));
  color: rgb(var(--dcr-comp-user-profile-text));
  padding: cds.get('cds.sys.spacing.s');
  border-radius: cds.get('cds.sys.shape.medium');
  display: flex;
  align-items: center;

  &.expanded {
    flex-direction: column;
    align-items: flex-start;
  }

  img {
    width: 50px;
    height: 50px;
    border-radius: cds.get('cds.sys.shape.full');
    margin-right: cds.get('cds.sys.spacing.s');
  }

  button {
    background-color: transparent;
    color: rgb(var(--dcr-comp-user-profile-accent));
    border: none;
    cursor: pointer;
    padding: cds.get('cds.sys.spacing.2xs') cds.get('cds.sys.spacing.xs');

    &:hover {
      text-decoration: underline;
    }
  }
}

.details {
  margin-top: cds.get('cds.sys.spacing.s');
  padding-top: cds.get('cds.sys.spacing.s');
  border-top: 1px solid rgba(var(--dcr-comp-user-profile-text), 0.1);
  width: 100%;
}

3. Events en communicatie ​

typescript
// notification-list.ts
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import styles from './notification-list.scss';

interface Notification {
  id: string;
  message: string;
  read: boolean;
}

@customElement('notification-list')
export class NotificationList extends LitElement {
  static override styles = styles;

  @property({ type: Array })
  public notifications: Notification[] = [];

  override render() {
    return html`
      <div class="notifications">
        <h3>Notificaties (${this._unreadCount})</h3>
        <ul>
          ${this.notifications.map(
            (notification) => html`
              <li class="${notification.read ? 'read' : 'unread'}">
                ${notification.message} ${!notification.read
                  ? html` <button @click=${() => this._markAsRead(notification.id)}>Markeer als gelezen</button> `
                  : ''}
              </li>
            `
          )}
        </ul>
      </div>
    `;
  }

  private get _unreadCount() {
    return this.notifications.filter((n) => !n.read).length;
  }

  private mark_AsRead(id: string) {
    // Custom event met details
    this.dispatchEvent(
      new CustomEvent('notification-read', {
        detail: { id },
        bubbles: true,
        composed: true, // Belangrijk voor Shadow DOM grenzen
      })
    );
  }
}

4. Storybook met automatische story generatie ​

Storybook speelt een centrale rol in onze workflow, met een geautomatiseerde aanpak voor story-creatie:

  • Ontwikkeling: Playground voor het ontwikkelen en testen van componenten in isolatie
  • Documentatie: Levende documentatie van onze componentenbibliotheek
  • Visuele regressietests: Automatische visuele tests om regressies te detecteren
  • Toegankelijkheidstests: A11y tests om toegankelijkheidsproblemen te identificeren
  • Interactieve tests: Tests voor gebruikersinteracties

Automatische story generatie ​

Onze workflow maakt gebruik van een geautomatiseerd systeem voor het genereren van story-bestanden:

  1. Base stories generatie: Wanneer we npx nx serve dcr-components uitvoeren, worden automatisch .stories.base.ts bestanden gegenereerd op basis van de component eigenschappen.

  2. Metadata extractie: Het systeem analyseert de component en extraheert:

    • Properties en hun types
    • CSS custom properties
    • Events
    • Slots
    • Documentatie
  3. Story uitbreiding: Ontwikkelaars kunnen vervolgens eenvoudig stories toevoegen door de gegenereerde base file te importeren en uit te breiden.

Vervolgens kunnen ontwikkelaars eenvoudig specifieke stories maken door de gegenereerde basis te gebruiken:

typescript
// Handmatig uitgebreid: dcr-badge.stories.ts
import { StoryObj } from '@storybook/web-components';

import '../dcr-badge.js';
import { DcrBadgeProps, meta } from './dcr-badge.stories.base.js';

export default {
  title: 'Components/dcr-badge',
  tags: ['autodocs', 'kickstart'],
  ...meta,
};

export type dcrBadgeStory = StoryObj<DcrBadgeProps>;

// Eenvoudige story definitie met alleen de benodigde args
export const Default: dcrBadgeStory = { args: { count: 7 } };

export const DefaultTiny: dcrBadgeStory = { args: { tiny: true } };

export const WarnTiny: dcrBadgeStory = {
  name: 'Tiny with warn status',
  args: { tiny: true, status: 'warn' },
};

export const SuccessTiny: dcrBadgeStory = {
  name: 'Tiny with success status',
  args: { tiny: true, status: 'success' },
};

export const AiTiny: dcrBadgeStory = {
  name: 'Tiny with ai status',
  args: { tiny: true, status: 'ai' },
};

Voordelen van onze automatische story generatie ​

  1. Consistentie: Zorgt voor consistente documentatie en stories over alle componenten heen
  2. Tijdsbesparing: Automatiseert het repetitieve werk van story setup
  3. Volledigheid: Zorgt ervoor dat alle properties, CSS custom properties en events gedocumenteerd zijn
  4. Onderhoudsvriendelijkheid: Bij wijzigingen in de component worden de base stories automatisch bijgewerkt
  5. Lagere drempel: Maakt het gemakkelijker voor ontwikkelaars om stories toe te voegen zonder diepgaande Storybook-kennis

Deze aanpak stelt ons in staat om snel en efficiënt stories te maken voor onze componenten, wat leidt tot betere documentatie en testdekking.

Integratie met Angular ​

Hoewel onze LIT components framework-agnostisch zijn, gebruiken we ze vaak binnen onze Angular applicaties. Hier is hoe we ze integreren:

1. CUSTOM_ELEMENTS_SCHEMA ​

typescript
// app.module.ts
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';

// Importeer de LIT componenten
import '@diekeure/dcr-components';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA], // Hiermee kan Angular custom elements herkennen
})
export class AppModule {}

2. Angular component wrapper (optioneel) ​

Hoewel webcomponenten direct in Angular templates kunnen worden gebruikt, biedt het maken van een Angular component wrapper rond een LIT component verschillende voordelen:

typescript
// notification-list.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Notification } from '@diekeure/dcr-components';

@Component({
  selector: 'app-notification-list',
  template: `
    <notification-list
      [notifications]="notifications"
      (notification-read)="onNotificationRead($event)"
    ></notification-list>
  `,
})
export class NotificationListComponent {
  @Input() notifications: Notification[] = [];
  @Output() notificationRead = new EventEmitter<{ id: string }>();

  onNotificationRead(event: CustomEvent) {
    this.notificationRead.emit(event.detail);
  }
}

Voordelen van Angular wrappers ​

  1. Type-veiligheid: De wrapper biedt volledige TypeScript type-checking voor inputs en outputs, wat niet altijd het geval is bij directe webcomponent-gebruik.

  2. Angular-idiomatisch: Wrappers laten je Angular-idiomatische patronen gebruiken zoals @Input() en @Output() decorators, wat de code consistenter maakt met de rest van je Angular applicatie.

  3. Event transformatie: Je kunt CustomEvents van webcomponenten transformeren naar Angular's EventEmitter, wat beter integreert met Angular's change detection.

  4. Lazy loading: Angular wrappers kunnen worden lazy-loaded als onderdeel van Angular modules, wat meer controle geeft over bundel-grootte.

  5. Angular services integratie: Wrappers kunnen Angular services injecteren en deze functionaliteit doorgeven aan de webcomponent.

  6. Forms integratie: Een wrapper kan ControlValueAccessor implementeren om naadloos te werken met Angular's reactive forms of template-driven forms.

Voorbeeld van een uitgebreidere wrapper ​

typescript
// notification-list.component.ts
import {
  Component,
  Input,
  Output,
  EventEmitter,
  OnChanges,
  SimpleChanges,
  ChangeDetectionStrategy,
  OnInit,
  OnDestroy,
  inject,
} from '@angular/core';
import { Notification, NotificationService } from '@diekeure/dcr-components';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-notification-list',
  template: `
    <notification-list
      [notifications]="notifications"
      (notification-read)="onNotificationRead($event)"
    ></notification-list>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationListComponent implements OnInit, OnChanges, OnDestroy {
  private notificationService = inject(NotificationService);
  private subscription = new Subscription();

  @Input() notifications: Notification[] = [];
  @Input() autoRefresh = false;
  @Output() notificationRead = new EventEmitter<{ id: string }>();
  @Output() notificationsChanged = new EventEmitter<Notification[]>();

  ngOnInit() {
    // Integratie met Angular services
    if (this.autoRefresh) {
      this.subscription.add(
        this.notificationService.getNotifications().subscribe((notifications) => {
          this.notifications = notifications;
          this.notificationsChanged.emit(notifications);
        })
      );
    }
  }

  ngOnChanges(changes: SimpleChanges) {
    // Reageren op Angular input wijzigingen
    if (changes['notifications'] && !changes['notifications'].firstChange) {
      console.log('Notifications updated:', this.notifications);
    }
  }

  onNotificationRead(event: CustomEvent) {
    // Event transformatie van CustomEvent naar Angular EventEmitter
    this.notificationRead.emit(event.detail);

    // Integratie met Angular services
    this.notificationService.markAsRead(event.detail.id);
  }

  ngOnDestroy() {
    // Proper cleanup van Angular resources
    this.subscription.unsubscribe();
  }
}

Wanneer wrappers gebruiken? ​

Overweeg een Angular wrapper te maken wanneer:

  1. Je complexe Angular-integratie nodig hebt: Bijvoorbeeld, als je component moet integreren met Angular services, routing of forms.

  2. Je extra functionaliteit wilt toevoegen: Als je Angular-specifieke functionaliteit wilt toevoegen bovenop de basis webcomponent.

In veel eenvoudige gevallen kun je echter de LIT components direct gebruiken zonder wrapper, wat de overhead vermindert en de directe voordelen van webcomponenten behoudt.

Conclusie ​

LIT components bieden een krachtige, efficiënte en toekomstbestendige manier om framework-agnostische componenten te bouwen binnen onze monorepo-structuur. Door gebruik te maken van webstandaarden zoals Custom Elements en Shadow DOM, zorgen we ervoor dat onze core components overal werken - van onze Angular applicaties tot Learnosity Custom Questions en Rich Text Editors.

De combinatie van LIT met NX, Vite en Storybook geeft ons een robuuste ontwikkelingsworkflow die zowel efficiënt als onderhoudsarm is. De kleine footprint en uitstekende prestaties van LIT maken het een ideale keuze voor onze injecteerbare componenten, terwijl de sterke encapsulatie zorgt voor betrouwbare werking in diverse omgevingen.

Door LIT te gebruiken voor onze dcr-components repository, hebben we een consistente componentenbibliotheek gecreëerd die de basis vormt voor onze UI-elementen in verschillende applicaties en contexten, wat zorgt voor een uniforme gebruikerservaring en efficiëntere ontwikkeling.