Developer Guide

This guide covers everything you need to build a production-ready web component for Facets: the Custom Elements API, Shadow DOM lifecycle, state management, styling, and a complete annotated example.

Architecture Overview

Facets web components are built with the Custom Elements API: a browser-native standard that lets you define new HTML tags. Each component extends HTMLElement, uses Shadow DOM for style isolation, is registered with customElements.define() and lives in a single .js file with no build step or dependencies

When Facets loads your component, it:

  1. Fetches your .js file from the remoteURL you registered
  2. Injects it as a <script type="text/javascript"> tag (NOT an ES module)
  3. Creates an instance of your custom element
  4. Passes contextual attributes (user data, custom config)
  5. Renders it inside an isolated container in the sidebar or tab

Important: Plain Script Format (Not ES Modules)

Because Facets loads your file with <script type="text/javascript">, your component must be a plain, self-contained script:

  • No import or export statements: these are ES module syntax and will cause a syntax error
  • No top-level await: only allowed in ES modules
  • No external dependencies: everything must be inlined in the single .js file
  • The file should simply define your class and call customElements.define() at the top level
// CORRECT -- plain script, self-registering
class MyComponent extends HTMLElement { /* ... */ }
customElements.define('my-component', MyComponent);

// WRONG -- ES module syntax will fail
import { something } from './utils.js';  // SyntaxError!
export class MyComponent extends HTMLElement { /* ... */ }  // Not needed

Custom Elements Lifecycle

CallbackWhen it firesUse it for
constructor()Element createdInitialize state, attach Shadow DOM, render initial HTML
connectedCallback()Element added to DOMSet up event listeners, fetch initial data
disconnectedCallback()Element removed from DOMClean up timers, listeners, abort pending requests
attributeChangedCallback(name, oldVal, newVal)Observed attribute changesReact to external attribute updates

Required Pattern

class MyComponent extends HTMLElement {
  constructor() {
    super();  // MUST call super() first
    this.attachShadow({ mode: 'open' });

    // Initialize state
    this.data = [];
    this.isLoading = false;

    // Render initial structure
    this.render();
  }

  connectedCallback() {
    this.setupEventListeners();
    this.fetchData();
  }

  disconnectedCallback() {
    // Clean up any timers or listeners
    if (this.refreshTimer) {
      clearInterval(this.refreshTimer);
    }
  }
}

// Register at module scope -- NOT inside a function
customElements.define('my-component', MyComponent);

Shadow DOM

Shadow DOM provides style isolation -- your component's CSS won't affect the Facets UI, and platform styles won't leak into your component.

Key Rules

  • Always use this.attachShadow({ mode: 'open' }) in the constructor
  • Query elements with this.shadowRoot.querySelector(), NOT document.querySelector()
  • All styles go inside <style> tags within the Shadow DOM
  • Use :host to style the component's outer element
render() {
  this.shadowRoot.innerHTML = `
    <style>
      :host {
        display: block;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      }
      .container { padding: 1rem; }
      h2 { margin: 0 0 1rem; }
    </style>
    <div class="container">
      <h2>My Component</h2>
      <div id="content"></div>
    </div>
  `;
}

State Management

Store state as instance properties and update specific DOM elements rather than re-rendering the entire component.

constructor() {
  super();
  this.attachShadow({ mode: 'open' });

  // State
  this.items = [];
  this.currentPage = 0;
  this.pageSize = 10;
  this.totalPages = 0;
  this.isLoading = false;
  this.error = null;
  this.filters = {};

  this.render();
}

// Update specific elements instead of full re-render
updateTable() {
  const tbody = this.shadowRoot.getElementById('table-body');
  tbody.innerHTML = '';

  this.items.forEach(item => {
    const row = document.createElement('tr');
    row.innerHTML = `<td>${item.name}</td><td>${item.status}</td>`;
    tbody.appendChild(row);
  });
}

Event Handling

Set up event listeners in connectedCallback() using elements from the Shadow DOM.

setupEventListeners() {
  // Button clicks
  this.shadowRoot.getElementById('refresh-btn')
    .addEventListener('click', () => this.fetchData());

  // Form inputs
  this.shadowRoot.getElementById('search-input')
    .addEventListener('input', (e) => {
      this.filters.search = e.target.value;
    });

  // Keyboard shortcuts
  this.shadowRoot.getElementById('search-input')
    .addEventListener('keydown', (e) => {
      if (e.key === 'Enter') this.applyFilters();
    });
}

Styling Guidelines

CSS Custom Properties for Theming

Use CSS custom properties so the component adapts to different contexts.

:host {
  display: block;
  --primary-color: #0077cc;
  --border-color: #e0e0e0;
  --bg-hover: #f9f9f9;
  --bg-filter: #f5f5f5;
  --text-color: #333;
  --text-muted: #757575;
  --error-color: #d32f2f;
  --error-bg: #ffebee;
}

button {
  background: var(--primary-color);
  color: white;
  border: none;
  border-radius: 4px;
  padding: 0.5rem 1rem;
  cursor: pointer;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; border-bottom: 1px solid var(--border-color); text-align: left; }
th { background: var(--bg-filter); font-weight: 600; }
tr:hover { background: var(--bg-hover); }

Loading, Error, and Empty States

Always include all three states:

<!-- Loading -->
<div class="loading" id="loading" style="display:none;">Loading...</div>

<!-- Error -->
<div class="error" id="error" style="display:none;"></div>

<!-- Empty -->
<div class="empty" id="empty" style="display:none;">No results found.</div>
setLoading(show) {
  this.shadowRoot.getElementById('loading').style.display = show ? 'block' : 'none';
}

showError(message) {
  const el = this.shadowRoot.getElementById('error');
  el.textContent = message;
  el.style.display = 'block';
}

clearError() {
  const el = this.shadowRoot.getElementById('error');
  el.textContent = '';
  el.style.display = 'none';
}

Pagination Pattern

updatePagination() {
  const prev = this.shadowRoot.getElementById('prev');
  const next = this.shadowRoot.getElementById('next');
  const info = this.shadowRoot.getElementById('page-info');

  info.textContent = `Page ${this.currentPage + 1} of ${Math.max(1, this.totalPages)}`;
  prev.disabled = this.currentPage <= 0;
  next.disabled = this.currentPage >= this.totalPages - 1;
}

prevPage() {
  if (this.currentPage > 0) {
    this.currentPage--;
    this.fetchData();
  }
}

nextPage() {
  if (this.currentPage < this.totalPages - 1) {
    this.currentPage++;
    this.fetchData();
  }
}

Full Working Example: Audit Logs Viewer

Below is a complete, production-ready component that displays audit logs with filters and pagination. This demonstrates all the patterns described above.

class AuditLogsViewer extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.logs = [];
    this.currentPage = 0;
    this.pageSize = 10;
    this.totalPages = 0;
    this.isLoading = false;

    this.filters = {
      start: this.getDefaultStartDate(),
      end: new Date().toISOString(),
      number: 0,
      size: this.pageSize
    };

    this.render();
  }

  getDefaultStartDate() {
    const d = new Date();
    d.setDate(d.getDate() - 7);
    return d.toISOString();
  }

  connectedCallback() {
    this.setupEventListeners();
    this.fetchAuditLogs();
  }

  render() {
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; --primary: #0077cc; --border: #e0e0e0; }
        .container { padding: 1rem; }
        h2 { margin: 0 0 1rem; }
        .filters { display: flex; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem; padding: 1rem; background: #f5f5f5; border-radius: 4px; }
        .filter-group { display: flex; flex-direction: column; }
        label { font-size: 0.8rem; margin-bottom: 0.25rem; }
        input { padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; }
        button { background: var(--primary); color: white; border: none; border-radius: 4px; padding: 0.5rem 1rem; cursor: pointer; }
        button:disabled { background: #ccc; cursor: not-allowed; }
        .apply { align-self: flex-end; }
        table { width: 100%; border-collapse: collapse; }
        th, td { padding: 0.75rem; border-bottom: 1px solid var(--border); text-align: left; }
        th { background: #f5f5f5; font-weight: 600; }
        tr:hover { background: #f9f9f9; }
        .loading, .empty { text-align: center; padding: 2rem; color: #757575; }
        .error { color: #d32f2f; padding: 1rem; background: #ffebee; border-radius: 4px; margin-bottom: 1rem; }
        .pagination { display: flex; justify-content: center; gap: 1rem; align-items: center; margin-top: 1rem; }
      </style>
      <div class="container">
        <h2>Audit Logs</h2>
        <div class="filters">
          <div class="filter-group"><label>From</label><input type="datetime-local" id="start"></div>
          <div class="filter-group"><label>To</label><input type="datetime-local" id="end"></div>
          <div class="filter-group"><label>User</label><input type="text" id="user-filter" placeholder="Filter by user"></div>
          <button class="apply" id="apply">Apply Filters</button>
        </div>
        <div class="error" id="error" style="display:none;"></div>
        <div class="loading" id="loading" style="display:none;">Loading audit logs...</div>
        <table><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Target</th><th>Project</th></tr></thead><tbody id="tbody"></tbody></table>
        <div class="empty" id="empty" style="display:none;">No audit logs found.</div>
        <div class="pagination">
          <button id="prev" disabled>Previous</button>
          <span id="page-info">Page 1 of 1</span>
          <button id="next" disabled>Next</button>
        </div>
      </div>
    `;
  }

  setupEventListeners() {
    this.shadowRoot.getElementById('apply').addEventListener('click', () => this.applyFilters());
    this.shadowRoot.getElementById('prev').addEventListener('click', () => this.prevPage());
    this.shadowRoot.getElementById('next').addEventListener('click', () => this.nextPage());

    // Set initial date values
    const fmt = iso => new Date(iso).toISOString().slice(0, 16);
    this.shadowRoot.getElementById('start').value = fmt(this.filters.start);
    this.shadowRoot.getElementById('end').value = fmt(this.filters.end);
  }

  async fetchAuditLogs() {
    try {
      this.setLoading(true);
      this.clearError();
      const params = new URLSearchParams();
      Object.entries(this.filters).forEach(([k, v]) => {
        if (v !== null && v !== undefined && v !== '') params.append(k, v);
      });
      const res = await fetch('/cc-ui/v1/audit-logs?' + params);
      if (!res.ok) throw new Error('Failed: ' + res.status);
      const data = await res.json();
      this.logs = data.content || [];
      this.totalPages = data.totalPages || 0;
      this.currentPage = data.number || 0;
      this.updateTable();
      this.updatePagination();
    } catch (err) {
      this.showError(err.message);
    } finally {
      this.setLoading(false);
    }
  }

  updateTable() {
    const tbody = this.shadowRoot.getElementById('tbody');
    const empty = this.shadowRoot.getElementById('empty');
    tbody.innerHTML = '';
    if (!this.logs.length) { empty.style.display = 'block'; return; }
    empty.style.display = 'none';
    this.logs.forEach(log => {
      const row = document.createElement('tr');
      row.innerHTML = `
        <td>${new Date(log.performedAt).toLocaleString()}</td>
        <td>${log.performedBy || '-'}</td>
        <td>${log.entityActionLabel || log.entityAction || '-'}</td>
        <td>${log.target || '-'}</td>
        <td>${log.stackName || '-'}</td>
      `;
      tbody.appendChild(row);
    });
  }

  updatePagination() {
    this.shadowRoot.getElementById('page-info').textContent = `Page ${this.currentPage + 1} of ${Math.max(1, this.totalPages)}`;
    this.shadowRoot.getElementById('prev').disabled = this.currentPage <= 0;
    this.shadowRoot.getElementById('next').disabled = this.currentPage >= this.totalPages - 1;
  }

  applyFilters() {
    const start = this.shadowRoot.getElementById('start').value;
    const end = this.shadowRoot.getElementById('end').value;
    const user = this.shadowRoot.getElementById('user-filter').value;
    this.filters = {
      start: start ? new Date(start).toISOString() : this.getDefaultStartDate(),
      end: end ? new Date(end).toISOString() : new Date().toISOString(),
      number: 0, size: this.pageSize,
      ...(user && { performedBy: user })
    };
    this.fetchAuditLogs();
  }

  prevPage() { if (this.currentPage > 0) { this.filters.number = this.currentPage - 1; this.fetchAuditLogs(); } }
  nextPage() { if (this.currentPage < this.totalPages - 1) { this.filters.number = this.currentPage + 1; this.fetchAuditLogs(); } }
  setLoading(v) { this.shadowRoot.getElementById('loading').style.display = v ? 'block' : 'none'; }
  showError(m) { const e = this.shadowRoot.getElementById('error'); e.textContent = m; e.style.display = 'block'; }
  clearError() { const e = this.shadowRoot.getElementById('error'); e.textContent = ''; e.style.display = 'none'; }
}

customElements.define('audit-logs-viewer', AuditLogsViewer);

This example demonstrates: Shadow DOM isolation, CSS custom properties, filter UI, paginated API calls with relative URLs, loading/error/empty states, and table rendering.