Components API 🧩
Components in SigPro are native Web Components built on the Custom Elements standard. They provide a way to create reusable, encapsulated pieces of UI with reactive properties and automatic cleanup.
$.component(tagName, setupFunction, observedAttributes, useShadowDOM)
Creates a custom element with reactive properties and automatic dependency tracking.
import { $, html } from 'sigpro';
$.component('my-button', (props, { slot, emit }) => {
return html`
<button
class="btn"
@click=${() => emit('click')}
>
${slot()}
</button>
`;
}, ['variant']); // Observe the 'variant' attribute📋 API Reference
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
tagName | string | required | Custom element tag name (must include a hyphen, e.g., my-button) |
setupFunction | Function | required | Function that returns the component's template |
observedAttributes | string[] | [] | Attributes to observe for changes (become reactive props) |
useShadowDOM | boolean | false | true = Shadow DOM (encapsulated), false = Light DOM (inherits styles) |
Setup Function Parameters
The setup function receives two arguments:
props- Object containing reactive signals for each observed attributecontext- Object with helper methods and properties
Context Object Properties
| Property | Type | Description |
|---|---|---|
slot(name) | Function | Returns array of child nodes for the specified slot |
emit(name, detail) | Function | Dispatches a custom event |
select(selector) | Function | Query selector within component's root |
selectAll(selector) | Function | Query selector all within component's root |
host | HTMLElement | Reference to the custom element instance |
root | Node | Component's root (shadow root or element itself) |
onUnmount(callback) | Function | Register cleanup function |
🏠 Light DOM vs Shadow DOM
Light DOM (useShadowDOM = false) - Default
The component inherits global styles from the application. Perfect for components that should integrate with your site's design system.
// Button that uses global Tailwind CSS
$.component('tw-button', (props, { slot, emit }) => {
const variant = props.variant() || 'primary';
const variants = {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
outline: 'border border-blue-500 text-blue-500 hover:bg-blue-50'
};
return html`
<button
class="px-4 py-2 rounded font-semibold transition-colors ${variants[variant]}"
@click=${() => emit('click')}
>
${slot()}
</button>
`;
}, ['variant']);Shadow DOM (useShadowDOM = true) - Encapsulated
The component encapsulates its styles completely. External styles don't affect it, and its styles don't leak out.
// Calendar with encapsulated styles
$.component('ui-calendar', (props) => {
return html`
<style>
/* These styles won't affect the rest of the page */
.calendar {
font-family: system-ui, sans-serif;
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
}
.day.selected {
background: #2196f3;
color: white;
}
</style>
<div class="calendar">
${renderCalendar(props.date())}
</div>
`;
}, ['date'], true); // true = use Shadow DOM🎯 Basic Examples
Simple Counter Component
// counter.js
$.component('my-counter', (props) => {
const count = $(0);
return html`
<div class="counter">
<p>Count: ${count}</p>
<button @click=${() => count(c => c + 1)}>+</button>
<button @click=${() => count(c => c - 1)}>-</button>
<button @click=${() => count(0)}>Reset</button>
</div>
`;
});Usage:
<my-counter></my-counter>Component with Props
// greeting.js
$.component('my-greeting', (props) => {
const name = props.name() || 'World';
const greeting = $(() => `Hello, ${name}!`);
return html`
<div class="greeting">
<h1>${greeting}</h1>
<p>This is a greeting component.</p>
</div>
`;
}, ['name']); // Observe the 'name' attributeUsage:
<my-greeting name="John"></my-greeting>
<my-greeting name="Jane"></my-greeting>Component with Events
// toggle.js
$.component('my-toggle', (props, { emit }) => {
const isOn = $(props.initial() === 'on');
const toggle = () => {
isOn(!isOn());
emit('toggle', { isOn: isOn() });
emit(isOn() ? 'on' : 'off');
};
return html`
<button
class="toggle ${() => isOn() ? 'active' : ''}"
@click=${toggle}
>
${() => isOn() ? 'ON' : 'OFF'}
</button>
`;
}, ['initial']);Usage:
<my-toggle
initial="off"
@toggle=${(e) => console.log('Toggled:', e.detail)}
@on=${() => console.log('Turned on')}
@off=${() => console.log('Turned off')}
></my-toggle>🎨 Advanced Examples
Form Input Component
// form-input.js
$.component('form-input', (props, { emit }) => {
const value = $(props.value() || '');
const error = $(null);
const touched = $(false);
// Validation effect
$.effect(() => {
if (props.pattern() && touched()) {
const regex = new RegExp(props.pattern());
const isValid = regex.test(value());
error(isValid ? null : props.errorMessage() || 'Invalid input');
emit('validate', { isValid, value: value() });
}
});
const handleInput = (e) => {
value(e.target.value);
emit('update', e.target.value);
};
const handleBlur = () => {
touched(true);
};
return html`
<div class="form-group">
${props.label() ? html`
<label class="form-label">
${props.label()}
${props.required() ? html`<span class="required">*</span>` : ''}
</label>
` : ''}
<input
type="${props.type() || 'text'}"
class="form-control ${() => error() ? 'is-invalid' : ''}"
:value=${value}
@input=${handleInput}
@blur=${handleBlur}
placeholder="${props.placeholder() || ''}"
?disabled=${props.disabled}
?required=${props.required}
/>
${() => error() ? html`
<div class="error-message">${error()}</div>
` : ''}
${props.helpText() ? html`
<small class="help-text">${props.helpText()}</small>
` : ''}
</div>
`;
}, ['label', 'type', 'value', 'placeholder', 'disabled', 'required', 'pattern', 'errorMessage', 'helpText']);Usage:
<form-input
label="Email"
type="email"
required
pattern="^[^\s@]+@[^\s@]+\.[^\s@]+$"
errorMessage="Please enter a valid email"
@update=${(e) => formData.email = e.detail}
@validate=${(e) => setEmailValid(e.detail.isValid)}
>
</form-input>Modal/Dialog Component
// modal.js
$.component('my-modal', (props, { slot, emit, onUnmount }) => {
const isOpen = $(false);
// Handle escape key
const handleKeydown = (e) => {
if (e.key === 'Escape' && isOpen()) {
close();
}
};
$.effect(() => {
if (isOpen()) {
document.addEventListener('keydown', handleKeydown);
document.body.style.overflow = 'hidden';
} else {
document.removeEventListener('keydown', handleKeydown);
document.body.style.overflow = '';
}
});
// Cleanup on unmount
onUnmount(() => {
document.removeEventListener('keydown', handleKeydown);
document.body.style.overflow = '';
});
const open = () => {
isOpen(true);
emit('open');
};
const close = () => {
isOpen(false);
emit('close');
};
// Expose methods to parent
props.open = open;
props.close = close;
return html`
<div>
<!-- Trigger button -->
<button
class="modal-trigger"
@click=${open}
>
${slot('trigger') || 'Open Modal'}
</button>
<!-- Modal overlay -->
${() => isOpen() ? html`
<div class="modal-overlay" @click=${close}>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>${props.title() || 'Modal'}</h3>
<button class="close-btn" @click=${close}>×</button>
</div>
<div class="modal-body">
${slot('body')}
</div>
<div class="modal-footer">
${slot('footer') || html`
<button @click=${close}>Close</button>
`}
</div>
</div>
</div>
` : ''}
</div>
`;
}, ['title'], false);Usage:
<my-modal title="Confirm Delete">
<button slot="trigger">Delete Item</button>
<div slot="body">
<p>Are you sure you want to delete this item?</p>
<p class="warning">This action cannot be undone.</p>
</div>
<div slot="footer">
<button class="cancel" @click=${close}>Cancel</button>
<button class="delete" @click=${handleDelete}>Delete</button>
</div>
</my-modal>Data Table Component
// data-table.js
$.component('data-table', (props, { emit }) => {
const data = $(props.data() || []);
const columns = $(props.columns() || []);
const sortColumn = $(null);
const sortDirection = $('asc');
const filterText = $('');
// Computed: filtered and sorted data
const processedData = $(() => {
let result = [...data()];
// Filter
if (filterText()) {
const search = filterText().toLowerCase();
result = result.filter(row =>
Object.values(row).some(val =>
String(val).toLowerCase().includes(search)
)
);
}
// Sort
if (sortColumn()) {
const col = sortColumn();
const direction = sortDirection() === 'asc' ? 1 : -1;
result.sort((a, b) => {
if (a[col] < b[col]) return -direction;
if (a[col] > b[col]) return direction;
return 0;
});
}
return result;
});
const handleSort = (col) => {
if (sortColumn() === col) {
sortDirection(sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
sortColumn(col);
sortDirection('asc');
}
emit('sort', { column: col, direction: sortDirection() });
};
return html`
<div class="data-table">
<!-- Search input -->
<div class="table-toolbar">
<input
type="search"
:value=${filterText}
placeholder="Search..."
class="search-input"
/>
<span class="record-count">
${() => `${processedData().length} of ${data().length} records`}
</span>
</div>
<!-- Table -->
<table>
<thead>
<tr>
${columns().map(col => html`
<th
@click=${() => handleSort(col.field)}
class:sortable=${true}
class:sorted=${() => sortColumn() === col.field}
>
${col.label}
${() => sortColumn() === col.field ? html`
<span class="sort-icon">
${sortDirection() === 'asc' ? '↑' : '↓'}
</span>
` : ''}
</th>
`)}
</tr>
</thead>
<tbody>
${() => processedData().map(row => html`
<tr @click=${() => emit('row-click', row)}>
${columns().map(col => html`
<td>${row[col.field]}</td>
`)}
</tr>
`)}
</tbody>
</table>
<!-- Empty state -->
${() => processedData().length === 0 ? html`
<div class="empty-state">
No data found
</div>
` : ''}
</div>
`;
}, ['data', 'columns']);Usage:
const userColumns = [
{ field: 'id', label: 'ID' },
{ field: 'name', label: 'Name' },
{ field: 'email', label: 'Email' },
{ field: 'role', label: 'Role' }
];
const userData = [
{ id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'User' }
];<data-table
.data=${userData}
.columns=${userColumns}
@row-click=${(e) => console.log('Row clicked:', e.detail)}
>
</data-table>Tabs Component
// tabs.js
$.component('my-tabs', (props, { slot, emit }) => {
const activeTab = $(props.active() || 0);
// Get all tab headers from slots
const tabs = $(() => {
const headers = slot('tab');
return headers.map((node, index) => ({
index,
title: node.textContent,
content: slot(`panel-${index}`)[0]
}));
});
$.effect(() => {
emit('change', { index: activeTab(), tab: tabs()[activeTab()] });
});
return html`
<div class="tabs">
<div class="tab-headers">
${tabs().map(tab => html`
<button
class="tab-header ${() => activeTab() === tab.index ? 'active' : ''}"
@click=${() => activeTab(tab.index)}
>
${tab.title}
</button>
`)}
</div>
<div class="tab-panels">
${tabs().map(tab => html`
<div
class="tab-panel"
style="display: ${() => activeTab() === tab.index ? 'block' : 'none'}"
>
${tab.content}
</div>
`)}
</div>
</div>
`;
}, ['active']);Usage:
<my-tabs @change=${(e) => console.log('Tab changed:', e.detail)}>
<div slot="tab">Profile</div>
<div slot="panel-0">
<h3>Profile Settings</h3>
<form>...</form>
</div>
<div slot="tab">Security</div>
<div slot="panel-1">
<h3>Security Settings</h3>
<form>...</form>
</div>
<div slot="tab">Notifications</div>
<div slot="panel-2">
<h3>Notification Preferences</h3>
<form>...</form>
</div>
</my-tabs>Component with External Data
// user-profile.js
$.component('user-profile', (props, { emit, onUnmount }) => {
const user = $(null);
const loading = $(false);
const error = $(null);
// Fetch user data when userId changes
$.effect(() => {
const userId = props.userId();
if (!userId) return;
loading(true);
error(null);
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
user(data);
emit('loaded', data);
})
.catch(err => {
if (err.name !== 'AbortError') {
error(err.message);
emit('error', err);
}
})
.finally(() => loading(false));
// Cleanup: abort fetch if component unmounts or userId changes
onUnmount(() => controller.abort());
});
return html`
<div class="user-profile">
${() => loading() ? html`
<div class="spinner">Loading...</div>
` : error() ? html`
<div class="error">Error: ${error()}</div>
` : user() ? html`
<div class="user-info">
<img src="${user().avatar}" class="avatar" />
<h2>${user().name}</h2>
<p>${user().email}</p>
<p>Member since: ${new Date(user().joined).toLocaleDateString()}</p>
</div>
` : html`
<div class="no-user">No user selected</div>
`}
</div>
`;
}, ['user-id']);📦 Component Libraries
Building a Reusable Component Library
// components/index.js
import { $, html } from 'sigpro';
// Button component
export const Button = $.component('ui-button', (props, { slot, emit }) => {
const variant = props.variant() || 'primary';
const size = props.size() || 'md';
const sizes = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg'
};
const variants = {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
danger: 'bg-red-500 hover:bg-red-600 text-white'
};
return html`
<button
class="rounded font-semibold transition-colors ${sizes[size]} ${variants[variant]}"
?disabled=${props.disabled}
@click=${() => emit('click')}
>
${slot()}
</button>
`;
}, ['variant', 'size', 'disabled']);
// Card component
export const Card = $.component('ui-card', (props, { slot }) => {
return html`
<div class="card border rounded-lg shadow-sm overflow-hidden">
${props.title() ? html`
<div class="card-header bg-gray-50 px-4 py-3 border-b">
<h3 class="font-semibold">${props.title()}</h3>
</div>
` : ''}
<div class="card-body p-4">
${slot()}
</div>
${props.footer() ? html`
<div class="card-footer bg-gray-50 px-4 py-3 border-t">
${slot('footer')}
</div>
` : ''}
</div>
`;
}, ['title']);
// Badge component
export const Badge = $.component('ui-badge', (props, { slot }) => {
const type = props.type() || 'default';
const types = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800'
};
return html`
<span class="inline-block px-2 py-1 text-xs font-semibold rounded ${types[type]}">
${slot()}
</span>
`;
}, ['type']);
export { $, html };Usage:
import { Button, Card, Badge } from './components/index.js';
// Use components anywhere
const app = html`
<div>
<Card title="Welcome">
<p>This is a card component</p>
<div slot="footer">
<Button variant="primary" @click=${handleClick}>
Save Changes
</Button>
<Badge type="success">New</Badge>
</div>
</Card>
</div>
`;🎯 Decision Guide: Light DOM vs Shadow DOM
Use Light DOM (false) when... | Use Shadow DOM (true) when... |
|---|---|
| Component is part of your main app | Building a UI library for others |
| Using global CSS (Tailwind, Bootstrap) | Creating embeddable widgets |
| Need to inherit theme variables | Styles must be pixel-perfect everywhere |
| Working with existing design system | Component has complex, specific styles |
| Quick prototyping | Distributing to different projects |
| Form elements that should match site | Need style isolation/encapsulation |
📊 Summary
| Feature | Description |
|---|---|
| Native Web Components | Built on Custom Elements standard |
| Reactive Props | Observed attributes become signals |
| Two Rendering Modes | Light DOM (default) or Shadow DOM |
| Automatic Cleanup | Effects and listeners cleaned up on disconnect |
| Event System | Custom events with emit() |
| Slot Support | Full slot API for content projection |
| Zero Dependencies | Pure vanilla JavaScript |
Pro Tip: Start with Light DOM components for app-specific UI, and use Shadow DOM when building components that need to work identically across different projects or websites.