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:
- 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.
Updated about 1 hour ago