Developer Guide
Build production-ready Facets web components with the Custom Elements API: Shadow DOM lifecycle, state management, styling, and a full annotated example.
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:
- Fetches your
.jsfile from theremoteURLyou registered - Injects it as a
<script type="text/javascript">tag (NOT an ES module) - Creates an instance of your custom element
- Passes contextual attributes (user data, custom config)
- 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
importorexportstatements: 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
.jsfile - 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 neededCustom Elements Lifecycle
| Callback | When it fires | Use it for |
|---|---|---|
constructor() | Element created | Initialize state, attach Shadow DOM, render initial HTML |
connectedCallback() | Element added to DOM | Set up event listeners, fetch initial data |
disconnectedCallback() | Element removed from DOM | Clean up timers, listeners, abort pending requests |
attributeChangedCallback(name, oldVal, newVal) | Observed attribute changes | React 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(), NOTdocument.querySelector() - All styles go inside
<style>tags within the Shadow DOM - Use
:hostto 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.
Sample Web Component
A minimal two-file sample web component (index.html and a custom element script) you can clone, host on static HTTPS, and render inside Facets.
API Integration Guide
How Facets web components authenticate via session cookie inheritance, make REST calls with relative URLs, read user context, navigate, and handle API errors.