Skip to content

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.

javascript
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

ParameterTypeDefaultDescription
tagNamestringrequiredCustom element tag name (must include a hyphen, e.g., my-button)
setupFunctionFunctionrequiredFunction that returns the component's template
observedAttributesstring[][]Attributes to observe for changes (become reactive props)
useShadowDOMbooleanfalsetrue = Shadow DOM (encapsulated), false = Light DOM (inherits styles)

Setup Function Parameters

The setup function receives two arguments:

  1. props - Object containing reactive signals for each observed attribute
  2. context - Object with helper methods and properties

Context Object Properties

PropertyTypeDescription
slot(name)FunctionReturns array of child nodes for the specified slot
emit(name, detail)FunctionDispatches a custom event
select(selector)FunctionQuery selector within component's root
selectAll(selector)FunctionQuery selector all within component's root
hostHTMLElementReference to the custom element instance
rootNodeComponent's root (shadow root or element itself)
onUnmount(callback)FunctionRegister 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.

javascript
// 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.

javascript
// 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

javascript
// 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:

html
<my-counter></my-counter>

Component with Props

javascript
// 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' attribute

Usage:

html
<my-greeting name="John"></my-greeting>
<my-greeting name="Jane"></my-greeting>

Component with Events

javascript
// 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:

html
<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

javascript
// 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:

html
<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>
javascript
// 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}>&times;</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:

html
<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

javascript
// 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:

javascript
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' }
];
html
<data-table 
  .data=${userData}
  .columns=${userColumns}
  @row-click=${(e) => console.log('Row clicked:', e.detail)}
>
</data-table>

Tabs Component

javascript
// 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:

html
<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

javascript
// 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

javascript
// 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:

javascript
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 appBuilding a UI library for others
Using global CSS (Tailwind, Bootstrap)Creating embeddable widgets
Need to inherit theme variablesStyles must be pixel-perfect everywhere
Working with existing design systemComponent has complex, specific styles
Quick prototypingDistributing to different projects
Form elements that should match siteNeed style isolation/encapsulation

📊 Summary

FeatureDescription
Native Web ComponentsBuilt on Custom Elements standard
Reactive PropsObserved attributes become signals
Two Rendering ModesLight DOM (default) or Shadow DOM
Automatic CleanupEffects and listeners cleaned up on disconnect
Event SystemCustom events with emit()
Slot SupportFull slot API for content projection
Zero DependenciesPure 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.