Skip to main content

Overview

Web components are the strategic direction for Sakai frontend development. Sakai uses Lit to build reusable, encapsulated components with modern JavaScript.

Architecture

The web components are organized as a monorepo using:

Directory Structure

webcomponents/tool/src/main/frontend/
├── packages/                  # Individual component packages
│   ├── sakai-button/
│   ├── sakai-calendar/
│   ├── sakai-editor/
│   └── .../
├── bundle-entry-points/       # Bundle configurations
├── bundles/                   # Generated bundles
├── node_modules/              # Shared dependencies
├── package.json               # Workspace configuration
└── web-test-runner.config.js  # Test configuration

Base Classes

SakaiElement

Base class for components that render to the light DOM:
import { SakaiElement } from '@sakai-ui/sakai-element';
import { html } from 'lit';

export class MyComponent extends SakaiElement {
  
  static properties = {
    siteId: { type: String, attribute: "site-id" },
    data: { type: Array },
  };

  constructor() {
    super();
    this.siteId = "";
    this.data = [];
  }

  render() {
    return html`
      <div class="my-component">
        <h2>${this.tr("component_title")}</h2>
        ${this.data.map(item => html`
          <div class="item">${item.name}</div>
        `)}
      </div>
    `;
  }
}

customElements.define("my-component", MyComponent);

SakaiShadowElement

Base class for components using Shadow DOM:
import { SakaiShadowElement } from '@sakai-ui/sakai-element';
import { html, css } from 'lit';

export class MyShadowComponent extends SakaiShadowElement {
  
  static styles = css`
    :host {
      display: block;
      padding: 1rem;
    }
    
    .button {
      background-color: var(--sakai-primary-color);
      color: white;
      padding: 0.5rem 1rem;
      border: none;
      border-radius: 4px;
    }
  `;

  render() {
    return html`
      <button class="button">
        <slot></slot>
      </button>
    `;
  }
}

customElements.define("my-shadow-component", MyShadowComponent);

Creating a New Component

1

Create package directory

cd webcomponents/tool/src/main/frontend/packages
mkdir sakai-my-component
cd sakai-my-component
2

Create package.json

{
  "name": "@sakai-ui/sakai-my-component",
  "version": "1.0.0",
  "description": "My Sakai component",
  "main": "src/SakaiMyComponent.js",
  "license": "ECL-2.0",
  "dependencies": {
    "@sakai-ui/sakai-element": "file:../sakai-element",
    "lit": "^3.2.1"
  }
}
3

Create component source

Create src/SakaiMyComponent.js:
import { SakaiElement } from '@sakai-ui/sakai-element';
import { html } from 'lit';

export class SakaiMyComponent extends SakaiElement {
  
  static properties = {
    siteId: { type: String, attribute: "site-id" },
  };

  constructor() {
    super();
    this.siteId = "";
    this.loadTranslations("my-component");
  }

  async connectedCallback() {
    super.connectedCallback();
    await this.loadData();
  }

  async loadData() {
    const url = `/api/sites/${this.siteId}/data`;
    const response = await fetch(url);
    this.data = await response.json();
  }

  render() {
    return html`
      <div class="sakai-my-component">
        <h2>${this.tr("title")}</h2>
        <!-- Component content -->
      </div>
    `;
  }
}

customElements.define("sakai-my-component", SakaiMyComponent);
4

Create entry point

Create sakai-my-component.js:
export { SakaiMyComponent } from "./src/SakaiMyComponent.js";
5

Create i18n strings

Create src/i18n/my-component.properties:
title=My Component
save=Save
cancel=Cancel
6

Create tests

Create test/sakai-my-component.test.js (see Testing section).

Reactive Properties

Define reactive properties that trigger re-renders:
static properties = {
  // Public properties (attributes)
  siteId: { type: String, attribute: "site-id" },
  count: { type: Number },
  enabled: { type: Boolean },
  
  // Internal state (no attribute)
  _data: { type: Array, state: true },
  _loading: { type: Boolean, state: true },
};
Prefix internal reactive state with _ and use state: true instead of attribute.

Internationalization

Load Translations

constructor() {
  super();
  this.loadTranslations("my-component").then(r => this.i18n = r);
}

Use Translations

render() {
  return html`
    <h2>${this.tr("title")}</h2>
    <button>${this.tr("save_button")}</button>
  `;
}

Translation Files

Create property files for each locale:
src/i18n/
├── my-component.properties        # English (default)
├── my-component_es_ES.properties  # Spanish
├── my-component_fr_FR.properties  # French
└── my-component_ja_JP.properties  # Japanese

Styling

Bootstrap Classes

Use Bootstrap 5.2 classes:
render() {
  return html`
    <div class="container">
      <div class="row">
        <div class="col-md-6">
          <button class="btn btn-primary">
            ${this.tr("save")}
          </button>
        </div>
      </div>
    </div>
  `;
}

Custom Styles (Shadow DOM)

For Shadow DOM components:
import { css } from 'lit';

static styles = css`
  :host {
    display: block;
  }
  
  .container {
    padding: 1rem;
  }
  
  button {
    background-color: var(--sakai-primary-color);
  }
`;

CSS Variables

Use Sakai CSS custom properties:
var(--sakai-primary-color)
var(--sakai-secondary-color)
var(--sakai-background-color)
var(--sakai-text-color)
var(--sakai-border-color)

Data Fetching

Fetch API

Use modern fetch with async/await:
async loadData() {
  try {
    this._loading = true;
    const response = await fetch(`/api/sites/${this.siteId}/data`, {
      credentials: "include",
      headers: { "Content-Type": "application/json" },
    });
    
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    
    this._data = await response.json();
  } catch (error) {
    console.error("Failed to load data:", error);
  } finally {
    this._loading = false;
  }
}

POST Requests

async saveData(data) {
  const response = await fetch(`/api/sites/${this.siteId}/data`, {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  
  return response.json();
}

Event Handling

Emit Custom Events

handleSave() {
  const event = new CustomEvent("item-saved", {
    detail: { itemId: this.itemId },
    bubbles: true,
    composed: true,
  });
  this.dispatchEvent(event);
}

Listen to Events

render() {
  return html`
    <button @click=${this.handleClick}>
      Click Me
    </button>
    <input @input=${this.handleInput} />
  `;
}

handleClick(e) {
  console.log("Button clicked");
}

handleInput(e) {
  this.value = e.target.value;
}

Testing

Create Test File

Create test/sakai-my-component.test.js:
import "../sakai-my-component.js";
import { expect, fixture, html, waitUntil } from "@open-wc/testing";
import fetchMock from "fetch-mock";

describe("sakai-my-component tests", () => {
  
  afterEach(() => fetchMock.restore());

  it("renders correctly", async () => {
    const el = await fixture(html`
      <sakai-my-component site-id="test-site"></sakai-my-component>
    `);

    expect(el).to.exist;
    await expect(el).to.be.accessible();
  });

  it("loads data from API", async () => {
    const mockData = [{ id: 1, name: "Test" }];
    
    fetchMock.get("/api/sites/test-site/data", mockData, {
      overwriteRoutes: true,
    });

    const el = await fixture(html`
      <sakai-my-component site-id="test-site"></sakai-my-component>
    `);

    await waitUntil(() => el._data.length > 0);
    expect(el._data).to.deep.equal(mockData);
  });

  it("handles button click", async () => {
    const el = await fixture(html`
      <sakai-my-component></sakai-my-component>
    `);

    const button = el.shadowRoot.querySelector("button");
    button.click();
    
    // Assert expected behavior
  });
});

Run Tests

cd webcomponents/tool/src/main/frontend
npm run test

Run Tests in Watch Mode

npx wtr --watch

Development Commands

Linting

npm run lint

Type Checking

npm run analyze

Bundling

npm run bundle

Bundle Analysis

npm run analyze-bundle

Best Practices

  • Use ES2022+ features (async/await, optional chaining, nullish coalescing)
  • Target evergreen browsers only
  • No jQuery - use native DOM APIs
  • Use fetch with keepalive support
  • Keep internal state prefixed with _
  • Use getters/setters for computed properties
  • Avoid global variables
  • Prefer module scope or class fields
  • Use semantic HTML
  • Add ARIA labels where needed
  • Ensure keyboard navigation works
  • Test with screen readers
  • Use await expect(el).to.be.accessible() in tests
  • Lazy load components when possible
  • Use requestAnimationFrame for animations
  • Debounce expensive operations
  • Minimize re-renders

Integration with Sakai Tools

Use in JSP/ThymeLeaf

<!-- Include bundle -->
<script type="module" src="/webcomponents/bundles/my-component.js"></script>

<!-- Use component -->
<sakai-my-component site-id="${siteId}"></sakai-my-component>

Pass Data to Components

<sakai-my-component 
  site-id="${siteId}"
  user-id="${currentUser.id}"
  data='${jsonData}'>
</sakai-my-component>

Next Steps

Testing Guide

Learn to write comprehensive tests

Contributing

Submit your components to Sakai

Build docs developers (and LLMs) love