Files
sigpro/docs/src/api/components.md
2026-03-17 12:01:25 +01:00

19 KiB

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:

  1. props - Object containing reactive signals for each observed attribute
  2. context - 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' attribute

Usage:

<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}>&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:

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