This commit is contained in:
2026-03-17 12:01:25 +01:00
parent 54ff7759ff
commit 685196ffab
90 changed files with 539 additions and 879 deletions

595
docs/api/components.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,760 +0,0 @@
# 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
| 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.
```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>
```
### Modal/Dialog Component
```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 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.

787
docs/api/effects.html Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

873
docs/api/fetch.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,998 +0,0 @@
# Fetch API 🌐
SigPro provides a simple, lightweight wrapper around the native Fetch API that integrates seamlessly with signals for loading state management. It's designed for common use cases with sensible defaults.
## Core Concepts
### What is `$.fetch`?
A ultra-simple fetch wrapper that:
- **Automatically handles JSON** serialization and parsing
- **Integrates with signals** for loading state
- **Returns `null` on error** (no try/catch needed for basic usage)
- **Works great with effects** for reactive data fetching
## `$.fetch(url, data, [loading])`
Makes a POST request with JSON data and optional loading signal.
```javascript
import { $ } from 'sigpro';
const loading = $(false);
async function loadUser() {
const user = await $.fetch('/api/user', { id: 123 }, loading);
if (user) {
console.log('User loaded:', user);
}
}
```
## 📋 API Reference
### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `url` | `string` | Endpoint URL |
| `data` | `Object` | Data to send (automatically JSON.stringify'd) |
| `loading` | `Function` (optional) | Signal function to track loading state |
### Returns
| Return | Description |
|--------|-------------|
| `Promise<Object\|null>` | Parsed JSON response or `null` on error |
## 🎯 Basic Examples
### Simple Data Fetching
```javascript
import { $ } from 'sigpro';
const userData = $(null);
async function fetchUser(id) {
const data = await $.fetch('/api/user', { id });
if (data) {
userData(data);
}
}
fetchUser(123);
```
### With Loading State
```javascript
import { $, html } from 'sigpro';
const user = $(null);
const loading = $(false);
async function loadUser(id) {
const data = await $.fetch('/api/user', { id }, loading);
if (data) user(data);
}
// In your template
html`
<div>
${() => loading() ? html`
<div class="spinner">Loading...</div>
` : user() ? html`
<div>
<h2>${user().name}</h2>
<p>Email: ${user().email}</p>
</div>
` : html`
<p>No user found</p>
`}
</div>
`;
```
### In an Effect
```javascript
import { $ } from 'sigpro';
const userId = $(1);
const user = $(null);
const loading = $(false);
$.effect(() => {
const id = userId();
if (id) {
$.fetch(`/api/users/${id}`, null, loading).then(data => {
if (data) user(data);
});
}
});
userId(2); // Automatically fetches new user
```
## 🚀 Advanced Examples
### User Profile with Loading States
```javascript
import { $, html } from 'sigpro';
const Profile = () => {
const userId = $(1);
const user = $(null);
const loading = $(false);
const error = $(null);
const fetchUser = async (id) => {
error(null);
const data = await $.fetch('/api/user', { id }, loading);
if (data) {
user(data);
} else {
error('Failed to load user');
}
};
// Fetch when userId changes
$.effect(() => {
fetchUser(userId());
});
return html`
<div class="profile">
<div class="user-selector">
<button @click=${() => userId(1)}>User 1</button>
<button @click=${() => userId(2)}>User 2</button>
<button @click=${() => userId(3)}>User 3</button>
</div>
${() => {
if (loading()) {
return html`<div class="spinner">Loading profile...</div>`;
}
if (error()) {
return html`<div class="error">${error()}</div>`;
}
if (user()) {
return html`
<div class="user-info">
<h2>${user().name}</h2>
<p>Email: ${user().email}</p>
<p>Role: ${user().role}</p>
<p>Joined: ${new Date(user().joined).toLocaleDateString()}</p>
</div>
`;
}
return html`<p>Select a user</p>`;
}}
</div>
`;
};
```
### Todo List with API
```javascript
import { $, html } from 'sigpro';
const TodoApp = () => {
const todos = $([]);
const loading = $(false);
const newTodo = $('');
const filter = $('all'); // 'all', 'active', 'completed'
// Load todos
const loadTodos = async () => {
const data = await $.fetch('/api/todos', {}, loading);
if (data) todos(data);
};
// Add todo
const addTodo = async () => {
if (!newTodo().trim()) return;
const todo = await $.fetch('/api/todos', {
text: newTodo(),
completed: false
});
if (todo) {
todos([...todos(), todo]);
newTodo('');
}
};
// Toggle todo
const toggleTodo = async (id, completed) => {
const updated = await $.fetch(`/api/todos/${id}`, {
completed: !completed
});
if (updated) {
todos(todos().map(t =>
t.id === id ? updated : t
));
}
};
// Delete todo
const deleteTodo = async (id) => {
const result = await $.fetch(`/api/todos/${id}/delete`, {});
if (result) {
todos(todos().filter(t => t.id !== id));
}
};
// Filtered todos
const filteredTodos = $(() => {
const currentFilter = filter();
if (currentFilter === 'all') return todos();
if (currentFilter === 'active') {
return todos().filter(t => !t.completed);
}
return todos().filter(t => t.completed);
});
// Load on mount
loadTodos();
return html`
<div class="todo-app">
<h1>Todo List</h1>
<div class="add-todo">
<input
type="text"
:value=${newTodo}
@keydown.enter=${addTodo}
placeholder="Add a new todo..."
/>
<button @click=${addTodo}>Add</button>
</div>
<div class="filters">
<button
class:active=${() => filter() === 'all'}
@click=${() => filter('all')}
>
All
</button>
<button
class:active=${() => filter() === 'active'}
@click=${() => filter('active')}
>
Active
</button>
<button
class:active=${() => filter() === 'completed'}
@click=${() => filter('completed')}
>
Completed
</button>
</div>
${() => loading() ? html`
<div class="spinner">Loading todos...</div>
) : html`
<ul class="todo-list">
${filteredTodos().map(todo => html`
<li class="todo-item">
<input
type="checkbox"
:checked=${todo.completed}
@change=${() => toggleTodo(todo.id, todo.completed)}
/>
<span class:completed=${todo.completed}>${todo.text}</span>
<button @click=${() => deleteTodo(todo.id)}>🗑️</button>
</li>
`)}
</ul>
`}
</div>
`;
};
```
### Infinite Scroll with Pagination
```javascript
import { $, html } from 'sigpro';
const InfiniteScroll = () => {
const posts = $([]);
const page = $(1);
const loading = $(false);
const hasMore = $(true);
const error = $(null);
const loadMore = async () => {
if (loading() || !hasMore()) return;
const data = await $.fetch('/api/posts', {
page: page(),
limit: 10
}, loading);
if (data) {
if (data.posts.length === 0) {
hasMore(false);
} else {
posts([...posts(), ...data.posts]);
page(p => p + 1);
}
} else {
error('Failed to load posts');
}
};
// Intersection Observer for infinite scroll
$.effect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.1 }
);
const sentinel = document.getElementById('sentinel');
if (sentinel) observer.observe(sentinel);
return () => observer.disconnect();
});
// Initial load
loadMore();
return html`
<div class="infinite-scroll">
<h1>Posts</h1>
<div class="posts">
${posts().map(post => html`
<article class="post">
<h2>${post.title}</h2>
<p>${post.body}</p>
<small>By ${post.author}</small>
</article>
`)}
</div>
<div id="sentinel" class="sentinel">
${() => {
if (loading()) {
return html`<div class="spinner">Loading more...</div>`;
}
if (error()) {
return html`<div class="error">${error()}</div>`;
}
if (!hasMore()) {
return html`<div class="end">No more posts</div>`;
}
return '';
}}
</div>
</div>
`;
};
```
### Search with Debounce
```javascript
import { $, html } from 'sigpro';
const SearchComponent = () => {
const query = $('');
const results = $([]);
const loading = $(false);
const error = $(null);
let searchTimeout;
const performSearch = async (searchQuery) => {
if (!searchQuery.trim()) {
results([]);
return;
}
const data = await $.fetch('/api/search', {
q: searchQuery
}, loading);
if (data) {
results(data);
} else {
error('Search failed');
}
};
// Debounced search
$.effect(() => {
const searchQuery = query();
clearTimeout(searchTimeout);
if (searchQuery.length < 2) {
results([]);
return;
}
searchTimeout = setTimeout(() => {
performSearch(searchQuery);
}, 300);
return () => clearTimeout(searchTimeout);
});
return html`
<div class="search">
<div class="search-box">
<input
type="search"
:value=${query}
placeholder="Search..."
class="search-input"
/>
${() => loading() ? html`
<span class="spinner-small">⌛</span>
) : ''}
</div>
${() => {
if (error()) {
return html`<div class="error">${error()}</div>`;
}
if (results().length > 0) {
return html`
<ul class="results">
${results().map(item => html`
<li class="result-item">
<h3>${item.title}</h3>
<p>${item.description}</p>
</li>
`)}
</ul>
`;
}
if (query().length >= 2 && !loading()) {
return html`<p class="no-results">No results found</p>`;
}
return '';
}}
</div>
`;
};
```
### Form Submission
```javascript
import { $, html } from 'sigpro';
const ContactForm = () => {
const formData = $({
name: '',
email: '',
message: ''
});
const submitting = $(false);
const submitError = $(null);
const submitSuccess = $(false);
const handleSubmit = async (e) => {
e.preventDefault();
submitError(null);
submitSuccess(false);
const result = await $.fetch('/api/contact', formData(), submitting);
if (result) {
submitSuccess(true);
formData({ name: '', email: '', message: '' });
} else {
submitError('Failed to send message. Please try again.');
}
};
const updateField = (field, value) => {
formData({
...formData(),
[field]: value
});
};
return html`
<form class="contact-form" @submit=${handleSubmit}>
<h2>Contact Us</h2>
<div class="form-group">
<label for="name">Name:</label>
<input
type="text"
id="name"
:value=${() => formData().name}
@input=${(e) => updateField('name', e.target.value)}
required
?disabled=${submitting}
/>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input
type="email"
id="email"
:value=${() => formData().email}
@input=${(e) => updateField('email', e.target.value)}
required
?disabled=${submitting}
/>
</div>
<div class="form-group">
<label for="message">Message:</label>
<textarea
id="message"
:value=${() => formData().message}
@input=${(e) => updateField('message', e.target.value)}
required
rows="5"
?disabled=${submitting}
></textarea>
</div>
${() => {
if (submitting()) {
return html`<div class="submitting">Sending...</div>`;
}
if (submitError()) {
return html`<div class="error">${submitError()}</div>`;
}
if (submitSuccess()) {
return html`<div class="success">Message sent successfully!</div>`;
}
return '';
}}
<button
type="submit"
?disabled=${submitting}
>
Send Message
</button>
</form>
`;
};
```
### Real-time Dashboard with Multiple Endpoints
```javascript
import { $, html } from 'sigpro';
const Dashboard = () => {
// Multiple data streams
const metrics = $({});
const alerts = $([]);
const logs = $([]);
const loading = $({
metrics: false,
alerts: false,
logs: false
});
const refreshInterval = $(5000); // 5 seconds
const fetchMetrics = async () => {
const data = await $.fetch('/api/metrics', {}, loading().metrics);
if (data) metrics(data);
};
const fetchAlerts = async () => {
const data = await $.fetch('/api/alerts', {}, loading().alerts);
if (data) alerts(data);
};
const fetchLogs = async () => {
const data = await $.fetch('/api/logs', {
limit: 50
}, loading().logs);
if (data) logs(data);
};
// Auto-refresh all data
$.effect(() => {
fetchMetrics();
fetchAlerts();
fetchLogs();
const interval = setInterval(() => {
fetchMetrics();
fetchAlerts();
}, refreshInterval());
return () => clearInterval(interval);
});
return html`
<div class="dashboard">
<header>
<h1>System Dashboard</h1>
<div class="refresh-control">
<label>
Refresh interval:
<select :value=${refreshInterval} @change=${(e) => refreshInterval(parseInt(e.target.value))}>
<option value="2000">2 seconds</option>
<option value="5000">5 seconds</option>
<option value="10000">10 seconds</option>
<option value="30000">30 seconds</option>
</select>
</label>
</div>
</header>
<div class="dashboard-grid">
<!-- Metrics Panel -->
<div class="panel metrics">
<h2>System Metrics</h2>
${() => loading().metrics ? html`
<div class="spinner">Loading metrics...</div>
) : html`
<div class="metrics-grid">
<div class="metric">
<label>CPU</label>
<span>${metrics().cpu || 0}%</span>
</div>
<div class="metric">
<label>Memory</label>
<span>${metrics().memory || 0}%</span>
</div>
<div class="metric">
<label>Requests</label>
<span>${metrics().requests || 0}/s</span>
</div>
</div>
`}
</div>
<!-- Alerts Panel -->
<div class="panel alerts">
<h2>Active Alerts</h2>
${() => loading().alerts ? html`
<div class="spinner">Loading alerts...</div>
) : alerts().length > 0 ? html`
<ul>
${alerts().map(alert => html`
<li class="alert ${alert.severity}">
<strong>${alert.type}</strong>
<p>${alert.message}</p>
<small>${new Date(alert.timestamp).toLocaleTimeString()}</small>
</li>
`)}
</ul>
) : html`
<p class="no-data">No active alerts</p>
`}
</div>
<!-- Logs Panel -->
<div class="panel logs">
<h2>Recent Logs</h2>
${() => loading().logs ? html`
<div class="spinner">Loading logs...</div>
) : html`
<ul>
${logs().map(log => html`
<li class="log ${log.level}">
<span class="timestamp">${new Date(log.timestamp).toLocaleTimeString()}</span>
<span class="message">${log.message}</span>
</li>
`)}
</ul>
`}
</div>
</div>
</div>
`;
};
```
### File Upload
```javascript
import { $, html } from 'sigpro';
const FileUploader = () => {
const files = $([]);
const uploading = $(false);
const uploadProgress = $({});
const uploadResults = $([]);
const handleFileSelect = (e) => {
files([...e.target.files]);
};
const uploadFiles = async () => {
if (files().length === 0) return;
uploading(true);
uploadResults([]);
for (const file of files()) {
const formData = new FormData();
formData.append('file', file);
// Track progress for this file
uploadProgress({
...uploadProgress(),
[file.name]: 0
});
try {
// Custom fetch for FormData
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
uploadResults([
...uploadResults(),
{ file: file.name, success: true, result }
]);
} catch (error) {
uploadResults([
...uploadResults(),
{ file: file.name, success: false, error: error.message }
]);
}
uploadProgress({
...uploadProgress(),
[file.name]: 100
});
}
uploading(false);
};
return html`
<div class="file-uploader">
<h2>Upload Files</h2>
<input
type="file"
multiple
@change=${handleFileSelect}
?disabled=${uploading}
/>
${() => files().length > 0 ? html`
<div class="file-list">
<h3>Selected Files:</h3>
<ul>
${files().map(file => html`
<li>
${file.name} (${(file.size / 1024).toFixed(2)} KB)
${() => uploadProgress()[file.name] ? html`
<progress value="${uploadProgress()[file.name]}" max="100"></progress>
) : ''}
</li>
`)}
</ul>
<button
@click=${uploadFiles}
?disabled=${uploading}
>
${() => uploading() ? 'Uploading...' : 'Upload Files'}
</button>
</div>
` : ''}
${() => uploadResults().length > 0 ? html`
<div class="upload-results">
<h3>Upload Results:</h3>
<ul>
${uploadResults().map(result => html`
<li class="${result.success ? 'success' : 'error'}">
${result.file}:
${result.success ? 'Uploaded successfully' : `Failed: ${result.error}`}
</li>
`)}
</ul>
</div>
` : ''}
</div>
`;
};
```
### Retry Logic
```javascript
import { $ } from 'sigpro';
// Enhanced fetch with retry
const fetchWithRetry = async (url, data, loading, maxRetries = 3) => {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (loading) loading(true);
const result = await $.fetch(url, data);
if (result !== null) {
return result;
}
// If we get null but no error, wait and retry
if (attempt < maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000) // Exponential backoff
);
}
} catch (error) {
lastError = error;
console.warn(`Attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
} finally {
if (attempt === maxRetries && loading) {
loading(false);
}
}
}
console.error('All retry attempts failed:', lastError);
return null;
};
// Usage
const loading = $(false);
const data = await fetchWithRetry('/api/unreliable-endpoint', {}, loading, 5);
```
## 🎯 Best Practices
### 1. Always Handle Null Responses
```javascript
// ❌ Don't assume success
const data = await $.fetch('/api/data');
console.log(data.property); // Might throw if data is null
// ✅ Check for null
const data = await $.fetch('/api/data');
if (data) {
console.log(data.property);
} else {
showError('Failed to load data');
}
```
### 2. Use with Effects for Reactivity
```javascript
// ❌ Manual fetching
button.addEventListener('click', async () => {
const data = await $.fetch('/api/data');
updateUI(data);
});
// ✅ Reactive fetching
const trigger = $(false);
$.effect(() => {
if (trigger()) {
$.fetch('/api/data').then(data => {
if (data) updateUI(data);
});
}
});
trigger(true); // Triggers fetch
```
### 3. Combine with Loading Signals
```javascript
// ✅ Always show loading state
const loading = $(false);
const data = $(null);
async function load() {
const result = await $.fetch('/api/data', {}, loading);
if (result) data(result);
}
// In template
html`
<div>
${() => loading() ? '<Spinner />' :
data() ? '<Data />' :
'<Empty />'}
</div>
`;
```
### 4. Cancel In-flight Requests
```javascript
// ✅ Use AbortController with effects
let controller;
$.effect(() => {
if (controller) {
controller.abort();
}
controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => {
if (!controller.signal.aborted) {
updateData(data);
}
});
return () => controller.abort();
});
```
## 📊 Error Handling
### Basic Error Handling
```javascript
const data = await $.fetch('/api/data');
if (!data) {
// Handle error (show message, retry, etc.)
}
```
### With Error Signal
```javascript
const data = $(null);
const error = $(null);
const loading = $(false);
async function loadData() {
error(null);
const result = await $.fetch('/api/data', {}, loading);
if (result) {
data(result);
} else {
error('Failed to load data');
}
}
```
---
> **Pro Tip:** Combine `$.fetch` with `$.effect` and loading signals for a complete reactive data fetching solution. The loading signal integration makes it trivial to show loading states in your UI.

405
docs/api/pages.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,497 +0,0 @@
# Pages API 📄
Pages in SigPro are special components designed for route-based navigation with **automatic cleanup**. When you navigate away from a page, all signals, effects, and event listeners created within that page are automatically cleaned up - no memory leaks, no manual cleanup needed.
## `$.page(setupFunction)`
Creates a page with automatic cleanup of all signals and effects when navigated away.
```javascript
import { $, html } from 'sigpro';
export default $.page(() => {
// All signals and effects created here
// will be automatically cleaned up on navigation
const count = $(0);
$.effect(() => {
console.log(`Count: ${count()}`);
});
return html`
<div>
<h1>My Page</h1>
<p>Count: ${count}</p>
<button @click=${() => count(c => c + 1)}>+</button>
</div>
`;
});
```
## 📋 API Reference
| Parameter | Type | Description |
|-----------|------|-------------|
| `setupFunction` | `Function` | Function that returns the page content. Receives context object with `params` and `onUnmount` |
### Context Object Properties
| Property | Type | Description |
|----------|------|-------------|
| `params` | `Object` | Route parameters passed to the page |
| `onUnmount` | `Function` | Register cleanup callbacks (alternative to automatic cleanup) |
## 🎯 Basic Usage
### Simple Page
```javascript
// pages/home.js
import { $, html } from 'sigpro';
export default $.page(() => {
const title = $('Welcome to SigPro');
return html`
<div class="home-page">
<h1>${title}</h1>
<p>This page will clean itself up when you navigate away.</p>
</div>
`;
});
```
### Page with Route Parameters
```javascript
// pages/user.js
import { $, html } from 'sigpro';
export default $.page(({ params }) => {
// Access route parameters
const userId = params.id;
const userData = $(null);
const loading = $(false);
// Auto-cleaned effect
$.effect(() => {
loading(true);
$.fetch(`/api/users/${userId}`, null, loading)
.then(data => userData(data));
});
return html`
<div>
${() => loading() ? html`
<div class="spinner">Loading...</div>
` : html`
<h1>User Profile: ${userData()?.name}</h1>
<p>Email: ${userData()?.email}</p>
`}
</div>
`;
});
```
## 🧹 Automatic Cleanup
The magic of `$.page` is automatic cleanup. Everything created inside the page is tracked and cleaned up:
```javascript
export default $.page(() => {
// ✅ Signals are auto-cleaned
const count = $(0);
const user = $(null);
// ✅ Effects are auto-cleaned
$.effect(() => {
document.title = `Count: ${count()}`;
});
// ✅ Event listeners are auto-cleaned
window.addEventListener('resize', handleResize);
// ✅ Intervals and timeouts are auto-cleaned
const interval = setInterval(() => {
refreshData();
}, 5000);
return html`<div>Page content</div>`;
});
// When navigating away: all signals, effects, listeners, intervals STOP
```
## 📝 Manual Cleanup with `onUnmount`
Sometimes you need custom cleanup logic. Use `onUnmount` for that:
```javascript
export default $.page(({ onUnmount }) => {
// WebSocket connection
const socket = new WebSocket('wss://api.example.com');
socket.onmessage = (event) => {
updateData(JSON.parse(event.data));
};
// Manual cleanup
onUnmount(() => {
socket.close();
console.log('WebSocket closed');
});
return html`<div>Real-time updates</div>`;
});
```
## 🔄 Integration with Router
Pages are designed to work seamlessly with `$.router`:
```javascript
import { $, html } from 'sigpro';
import HomePage from './pages/Home.js';
import UserPage from './pages/User.js';
import SettingsPage from './pages/Settings.js';
const routes = [
{ path: '/', component: HomePage },
{ path: '/user/:id', component: UserPage },
{ path: '/settings', component: SettingsPage },
];
// Mount router
document.body.appendChild($.router(routes));
```
## 💡 Practical Examples
### Example 1: Data Fetching Page
```javascript
// pages/posts.js
export default $.page(({ params }) => {
const posts = $([]);
const loading = $(true);
const error = $(null);
$.effect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(data => {
posts(data);
loading(false);
})
.catch(err => {
error(err.message);
loading(false);
});
});
return html`
<div class="posts-page">
<h1>Blog Posts</h1>
${() => loading() ? html`
<div class="loading">Loading posts...</div>
` : error() ? html`
<div class="error">Error: ${error()}</div>
` : html`
<div class="posts-grid">
${posts().map(post => html`
<article class="post-card">
<h2>${post.title}</h2>
<p>${post.excerpt}</p>
<a href="#/post/${post.id}">Read more</a>
</article>
`)}
</div>
`}
</div>
`;
});
```
### Example 2: Real-time Dashboard
```javascript
// pages/dashboard.js
export default $.page(({ onUnmount }) => {
const metrics = $({
cpu: 0,
memory: 0,
requests: 0
});
// Auto-refresh data
const refreshInterval = setInterval(async () => {
const data = await $.fetch('/api/metrics');
if (data) metrics(data);
}, 5000);
// Manual cleanup for interval
onUnmount(() => clearInterval(refreshInterval));
// Live clock
const currentTime = $(new Date());
const clockInterval = setInterval(() => {
currentTime(new Date());
}, 1000);
onUnmount(() => clearInterval(clockInterval));
return html`
<div class="dashboard">
<h1>System Dashboard</h1>
<div class="time">
Last updated: ${() => currentTime().toLocaleTimeString()}
</div>
<div class="metrics-grid">
<div class="metric-card">
<h3>CPU Usage</h3>
<p class="metric-value">${() => metrics().cpu}%</p>
</div>
<div class="metric-card">
<h3>Memory Usage</h3>
<p class="metric-value">${() => metrics().memory}%</p>
</div>
<div class="metric-card">
<h3>Requests/min</h3>
<p class="metric-value">${() => metrics().requests}</p>
</div>
</div>
</div>
`;
});
```
### Example 3: Multi-step Form
```javascript
// pages/checkout.js
export default $.page(({ onUnmount }) => {
const step = $(1);
const formData = $({
email: '',
address: '',
payment: ''
});
// Warn user before leaving
const handleBeforeUnload = (e) => {
if (step() < 3) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
onUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
});
const nextStep = () => step(s => Math.min(s + 1, 3));
const prevStep = () => step(s => Math.max(s - 1, 1));
return html`
<div class="checkout">
<h1>Checkout - Step ${step} of 3</h1>
${() => {
switch(step()) {
case 1:
return html`
<div class="step">
<h2>Email</h2>
<input
type="email"
:value=${() => formData().email}
@input=${(e) => formData({...formData(), email: e.target.value})}
/>
</div>
`;
case 2:
return html`
<div class="step">
<h2>Address</h2>
<textarea
:value=${() => formData().address}
@input=${(e) => formData({...formData(), address: e.target.value})}
></textarea>
</div>
`;
case 3:
return html`
<div class="step">
<h2>Payment</h2>
<input
type="text"
placeholder="Card number"
:value=${() => formData().payment}
@input=${(e) => formData({...formData(), payment: e.target.value})}
/>
</div>
`;
}
}}
<div class="buttons">
${() => step() > 1 ? html`
<button @click=${prevStep}>Previous</button>
` : ''}
${() => step() < 3 ? html`
<button @click=${nextStep}>Next</button>
` : html`
<button @click=${submitOrder}>Place Order</button>
`}
</div>
</div>
`;
});
```
### Example 4: Page with Tabs
```javascript
// pages/profile.js
export default $.page(({ params }) => {
const activeTab = $('overview');
const userData = $(null);
// Load user data
$.effect(() => {
$.fetch(`/api/users/${params.id}`)
.then(data => userData(data));
});
const tabs = {
overview: () => html`
<div>
<h3>Overview</h3>
<p>Username: ${userData()?.username}</p>
<p>Member since: ${userData()?.joined}</p>
</div>
`,
posts: () => html`
<div>
<h3>Posts</h3>
${userData()?.posts.map(post => html`
<div class="post">${post.title}</div>
`)}
</div>
`,
settings: () => html`
<div>
<h3>Settings</h3>
<label>
<input type="checkbox" :checked=${userData()?.emailNotifications} />
Email notifications
</label>
</div>
`
};
return html`
<div class="profile-page">
<h1>${() => userData()?.name}</h1>
<div class="tabs">
${Object.keys(tabs).map(tab => html`
<button
class:active=${() => activeTab() === tab}
@click=${() => activeTab(tab)}
>
${tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
`)}
</div>
<div class="tab-content">
${() => tabs[activeTab()]()}
</div>
</div>
`;
});
```
## 🎯 Advanced Patterns
### Page with Nested Routes
```javascript
// pages/settings/index.js
export default $.page(({ params }) => {
const section = params.section || 'general';
const sections = {
general: () => import('./general.js').then(m => m.default),
security: () => import('./security.js').then(m => m.default),
notifications: () => import('./notifications.js').then(m => m.default)
};
const currentSection = $(null);
$.effect(() => {
sections[section]().then(comp => currentSection(comp));
});
return html`
<div class="settings">
<nav>
<a href="#/settings/general">General</a>
<a href="#/settings/security">Security</a>
<a href="#/settings/notifications">Notifications</a>
</nav>
<div class="content">
${currentSection}
</div>
</div>
`;
});
```
### Page with Authentication
```javascript
// pages/dashboard.js
export default $.page(({ onUnmount }) => {
const isAuthenticated = $(false);
const authCheck = $.effect(() => {
const token = localStorage.getItem('token');
isAuthenticated(!!token);
});
// Redirect if not authenticated
$.effect(() => {
if (!isAuthenticated()) {
$.router.go('/login');
}
});
return html`
<div class="dashboard">
<h1>Protected Dashboard</h1>
<!-- Protected content -->
</div>
`;
});
```
## 📊 Summary
| Feature | Description |
|---------|-------------|
| **Automatic Cleanup** | All signals, effects, and resources auto-cleaned on navigation |
| **Memory Safe** | No memory leaks, even with complex nested effects |
| **Router Integration** | Designed to work perfectly with `$.router` |
| **Parameters** | Access route parameters via `params` object |
| **Manual Cleanup** | `onUnmount` for custom cleanup needs |
| **Zero Configuration** | Just wrap your page in `$.page()` and it works |
---
> **Pro Tip:** Always wrap route-based views in `$.page()` to ensure proper cleanup. This prevents memory leaks and ensures your app stays performant even after many navigation changes.

217
docs/api/quick.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,436 +0,0 @@
# Quick API Reference ⚡
A comprehensive reference for all SigPro APIs. Everything you need to build reactive web applications with signals and web components.
## 📋 API Functions Reference
| Function | Description | Example |
|----------|-------------|---------|
| **`$(initialValue)`** | Creates a reactive signal (getter/setter) | `const count = $(0)` |
| **`$(computedFn)`** | Creates a computed signal | `const full = $(() => first() + last())` |
| **`$.effect(fn)`** | Runs effect when dependencies change | `$.effect(() => console.log(count()))` |
| **`$.page(setupFn)`** | Creates a page with automatic cleanup | `$.page(() => html`<div>Page</div>`)` |
| **`$.component(tagName, setupFn, attrs, useShadow)`** | Creates reactive Web Component | `$.component('my-menu', setup, ['items'])` |
| **`$.router(routes)`** | Creates a hash-based router | `$.router([{path:'/', component:Home}])` |
| **`$.router.go(path)`** | Navigates to a route | `$.router.go('/user/42')` |
| **`$.fetch(url, data, loadingSignal)`** | Fetch wrapper with loading state | `const data = await $.fetch('/api', data, loading)` |
| **`$.storage(key, initialValue, storageType)`** | Persistent signal (local/sessionStorage) | `const theme = $.storage('theme', 'light')` |
| **`` html`...` ``** | Template literal for reactive HTML | `` html`<div>${count}</div>` `` |
### Signal Methods
| Method | Description | Example |
|--------|-------------|---------|
| **`signal()`** | Gets current value | `count()` |
| **`signal(newValue)`** | Sets new value | `count(5)` |
| **`signal(prev => new)`** | Updates using previous value | `count(c => c + 1)` |
### Component Context Properties
| Property | Description | Example |
|----------|-------------|---------|
| **`props`** | Reactive component properties | `props.title()` |
| **`slot(name)`** | Accesses slot content | `slot()` or `slot('footer')` |
| **`emit(event, data)`** | Dispatches custom event | `emit('update', value)` |
| **`onUnmount(cb)`** | Registers cleanup callback | `onUnmount(() => clearInterval(timer))` |
### Page Context Properties
| Property | Description | Example |
|----------|-------------|---------|
| **`params`** | Route parameters | `params.id`, `params.slug` |
| **`onUnmount(cb)`** | Registers cleanup callback | `onUnmount(() => clearInterval(timer))` |
### HTML Directives
| Directive | Description | Example |
|-----------|-------------|---------|
| **`@event`** | Event listener | `` @click=${handler} `` |
| **`:property`** | Two-way binding | `` :value=${signal} `` |
| **`?attribute`** | Boolean attribute | `` ?disabled=${signal} `` |
| **`.property`** | DOM property binding | `` .scrollTop=${value} `` |
| **`class:name`** | Conditional class | `` class:active=${isActive} `` |
<style>
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
th {
background-color: var(--vp-c-bg-soft);
padding: 0.75rem;
text-align: left;
font-weight: 600;
}
td {
padding: 0.75rem;
border-bottom: 1px solid var(--vp-c-divider);
}
tr:hover {
background-color: var(--vp-c-bg-soft);
}
code {
font-size: 0.9em;
padding: 0.2em 0.4em;
border-radius: 4px;
background-color: var(--vp-c-bg-mute);
}
</style>
## 📡 Signals - `$(initialValue)`
Creates a reactive value that notifies dependents when changed.
| Pattern | Example | Description |
|---------|---------|-------------|
| **Basic Signal** | `const count = $(0)` | Create signal with initial value |
| **Getter** | `count()` | Read current value |
| **Setter** | `count(5)` | Set new value directly |
| **Updater** | `count(prev => prev + 1)` | Update based on previous value |
| **Computed** | `const full = $(() => first() + last())` | Auto-updating derived signal |
### Examples
```javascript
// Basic signal
const count = $(0);
console.log(count()); // 0
count(5);
count(c => c + 1); // 6
// Computed signal
const firstName = $('John');
const lastName = $('Doe');
const fullName = $(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "John Doe"
firstName('Jane'); // fullName auto-updates to "Jane Doe"
```
## 🔄 Effects - `$.effect(fn)`
Executes a function and automatically re-runs when its dependencies change.
| Pattern | Example | Description |
|---------|---------|-------------|
| **Basic Effect** | `$.effect(() => console.log(count()))` | Run effect on dependency changes |
| **Cleanup** | `$.effect(() => { timer = setInterval(...); return () => clearInterval(timer) })` | Return cleanup function |
| **Stop Effect** | `const stop = $.effect(...); stop()` | Manually stop an effect |
### Examples
```javascript
// Auto-running effect
const count = $(0);
$.effect(() => {
console.log(`Count is: ${count()}`);
}); // Logs immediately and whenever count changes
// Effect with cleanup
const userId = $(1);
$.effect(() => {
const id = userId();
const timer = setInterval(() => fetchUser(id), 5000);
return () => clearInterval(timer); // Cleanup before re-run
});
```
## 📄 Pages - `$.page(setupFunction)`
Creates a page with automatic cleanup of all signals and effects when navigated away.
```javascript
// pages/about.js
import { $, html } from 'sigpro';
export default $.page(() => {
const count = $(0);
// Auto-cleaned on navigation
$.effect(() => {
document.title = `Count: ${count()}`;
});
return html`
<div>
<h1>About Page</h1>
<p>Count: ${count}</p>
<button @click=${() => count(c => c + 1)}>+</button>
</div>
`;
});
```
### With Parameters
```javascript
export default $.page(({ params, onUnmount }) => {
const userId = params.id;
// Manual cleanup if needed
const interval = setInterval(() => refresh(), 10000);
onUnmount(() => clearInterval(interval));
return html`<div>User: ${userId}</div>`;
});
```
## 🧩 Components - `$.component(tagName, setup, observedAttributes, useShadowDOM)`
Creates Custom Elements with reactive properties.
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `tagName` | `string` | required | Custom element tag (must include hyphen) |
| `setupFunction` | `Function` | required | Function that renders the component |
| `observedAttributes` | `string[]` | `[]` | Attributes to observe for changes |
| `useShadowDOM` | `boolean` | `false` | `true` = Shadow DOM (encapsulated), `false` = Light DOM |
### Light DOM Example (Default)
```javascript
// button.js - inherits global styles
$.component('my-button', (props, { slot, emit }) => {
return html`
<button
class="px-4 py-2 bg-blue-500 text-white rounded"
@click=${() => emit('click')}
>
${slot()}
</button>
`;
}, ['variant']); // Observe 'variant' attribute
```
### Shadow DOM Example
```javascript
// calendar.js - encapsulated styles
$.component('my-calendar', (props) => {
return html`
<style>
/* These styles are isolated */
.calendar {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
</style>
<div class="calendar">
${renderCalendar(props.date())}
</div>
`;
}, ['date'], true); // true = use Shadow DOM
```
## 🌐 Router - `$.router(routes)`
Creates a hash-based router with automatic page cleanup.
### Route Definition
```javascript
const routes = [
// Simple routes
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
// Routes with parameters
{ path: '/user/:id', component: UserPage },
{ path: '/user/:id/posts/:pid', component: PostPage },
// RegExp routes for advanced matching
{ path: /^\/posts\/(?<id>\d+)$/, component: PostPage },
];
```
### Usage
```javascript
import { $, html } from 'sigpro';
import Home from './pages/Home.js';
import User from './pages/User.js';
const router = $.router([
{ path: '/', component: Home },
{ path: '/user/:id', component: User },
]);
// Navigation
$.router.go('/user/42');
$.router.go('about'); // Same as '/about'
// In templates
html`
<nav>
<a href="#/">Home</a>
<a href="#/user/42">Profile</a>
<button @click=${() => $.router.go('/contact')}>
Contact
</button>
</nav>
`;
```
## 📦 Storage - `$.storage(key, initialValue, [storage])`
Persistent signal that syncs with localStorage or sessionStorage.
```javascript
// localStorage (default)
const theme = $.storage('theme', 'light');
const user = $.storage('user', null);
const settings = $.storage('settings', { notifications: true });
// sessionStorage
const tempData = $.storage('temp', {}, sessionStorage);
// Usage like a normal signal
theme('dark'); // Auto-saves to localStorage
console.log(theme()); // 'dark' (even after page refresh)
```
## 🌐 Fetch - `$.fetch(url, data, [loading])`
Simple fetch wrapper with automatic JSON handling.
```javascript
const loading = $(false);
async function loadUser(id) {
const user = await $.fetch(`/api/users/${id}`, null, loading);
if (user) userData(user);
}
// In template
html`
<div>
${() => loading() ? html`<spinner></spinner>` : html`
<p>${userData()?.name}</p>
`}
</div>
`;
```
## 🎨 Template Literals - `` html`...` ``
Creates reactive DOM fragments with directives.
### Directives Reference
| Directive | Example | Description |
|-----------|---------|-------------|
| **Event** | `@click=${handler}` | Add event listener |
| **Two-way binding** | `:value=${signal}` | Bind signal to input value |
| **Boolean attribute** | `?disabled=${signal}` | Toggle boolean attribute |
| **Property** | `.scrollTop=${value}` | Set DOM property directly |
| **Class toggle** | `class:active=${isActive}` | Toggle class conditionally |
### Examples
```javascript
const text = $('');
const isDisabled = $(false);
const activeTab = $('home');
html`
<!-- Event binding -->
<button @click=${() => count(c => c + 1)}>+</button>
<!-- Two-way binding -->
<input :value=${text} />
<p>You typed: ${text}</p>
<!-- Boolean attributes -->
<button ?disabled=${isDisabled}>Submit</button>
<!-- Class toggles -->
<div class:active=${activeTab() === 'home'}>
Home content
</div>
<!-- Property binding -->
<div .scrollTop=${scrollPosition}></div>
`;
```
## 🎯 Complete Component Example
```javascript
import { $, html } from 'sigpro';
// Create a component
$.component('user-profile', (props, { slot, emit }) => {
// Reactive state
const user = $(null);
const loading = $(false);
// Load user data when userId changes
$.effect(() => {
const id = props.userId();
if (id) {
loading(true);
$.fetch(`/api/users/${id}`, null, loading)
.then(data => user(data));
}
});
// Computed value
const fullName = $(() =>
user() ? `${user().firstName} ${user().lastName}` : ''
);
// Template
return html`
<div class="user-profile">
${() => loading() ? html`
<div class="spinner">Loading...</div>
` : user() ? html`
<h2>${fullName}</h2>
<p>Email: ${user().email}</p>
<button @click=${() => emit('select', user())}>
${slot('Select')}
</button>
` : html`
<p>User not found</p>
`}
</div>
`;
}, ['user-id']); // Observe userId attribute
```
<style>
table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
}
th {
background-color: var(--vp-c-bg-soft);
padding: 0.75rem;
text-align: left;
}
td {
padding: 0.75rem;
border-bottom: 1px solid var(--vp-c-divider);
}
tr:hover {
background-color: var(--vp-c-bg-soft);
}
code {
font-size: 0.9em;
padding: 0.2em 0.4em;
border-radius: 4px;
}
</style>

628
docs/api/routing.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,784 +0,0 @@
# Routing API 🌐
SigPro includes a simple yet powerful hash-based router designed for Single Page Applications (SPAs). It works everywhere with zero server configuration and integrates seamlessly with `$.page` for automatic cleanup.
## Why Hash-Based Routing?
Hash routing (`#/about`) works **everywhere** - no server configuration needed. Perfect for:
- Static sites and SPAs
- GitHub Pages, Netlify, any static hosting
- Local development without a server
- Projects that need to work immediately
## `$.router(routes)`
Creates a hash-based router that renders the matching component and handles navigation.
```javascript
import { $, html } from 'sigpro';
import HomePage from './pages/Home.js';
import AboutPage from './pages/About.js';
import UserPage from './pages/User.js';
const routes = [
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
{ path: '/user/:id', component: UserPage },
];
// Mount the router
document.body.appendChild($.router(routes));
```
## 📋 API Reference
### `$.router(routes)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `routes` | `Array<Route>` | Array of route configurations |
**Returns:** `HTMLDivElement` - Container that renders the current page
### `$.router.go(path)`
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | `string` | Route path to navigate to (automatically adds leading slash) |
### Route Object
| Property | Type | Description |
|----------|------|-------------|
| `path` | `string` or `RegExp` | Route pattern to match |
| `component` | `Function` | Function that returns page content (receives `params`) |
## 🎯 Route Patterns
### String Paths (Simple Routes)
```javascript
const routes = [
// Static routes
{ path: '/', component: HomePage },
{ path: '/about', component: AboutPage },
{ path: '/contact', component: ContactPage },
// Routes with parameters
{ path: '/user/:id', component: UserPage },
{ path: '/user/:id/posts', component: UserPostsPage },
{ path: '/user/:id/posts/:postId', component: PostPage },
{ path: '/search/:query/page/:num', component: SearchPage },
];
```
### RegExp Paths (Advanced Routing)
```javascript
const routes = [
// Match numeric IDs only
{ path: /^\/users\/(?<id>\d+)$/, component: UserPage },
// Match product slugs (letters, numbers, hyphens)
{ path: /^\/products\/(?<slug>[a-z0-9-]+)$/, component: ProductPage },
// Match blog posts by year/month
{ path: /^\/blog\/(?<year>\d{4})\/(?<month>\d{2})$/, component: BlogArchive },
// Match optional language prefix
{ path: /^\/(?<lang>en|es|fr)?\/?about$/, component: AboutPage },
// Match UUID format
{ path: /^\/items\/(?<uuid>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/,
component: ItemPage },
];
```
## 📦 Basic Examples
### Simple Router Setup
```javascript
// main.js
import { $, html } from 'sigpro';
import Home from './pages/Home.js';
import About from './pages/About.js';
import Contact from './pages/Contact.js';
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/contact', component: Contact },
];
const router = $.router(routes);
// Mount to DOM
document.body.appendChild(router);
```
### Page Components with Parameters
```javascript
// pages/User.js
import { $, html } from 'sigpro';
export default (params) => $.page(() => {
// /user/42 → params = { id: '42' }
// /user/john/posts/123 → params = { id: 'john', postId: '123' }
const userId = params.id;
const userData = $(null);
$.effect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => userData(data));
});
return html`
<div class="user-page">
<h1>User Profile: ${userId}</h1>
${() => userData() ? html`
<p>Name: ${userData().name}</p>
<p>Email: ${userData().email}</p>
` : html`<p>Loading...</p>`}
</div>
`;
});
```
### Navigation
```javascript
import { $, html } from 'sigpro';
// In templates
const NavBar = () => html`
<nav>
<a href="#/">Home</a>
<a href="#/about">About</a>
<a href="#/contact">Contact</a>
<a href="#/user/42">Profile</a>
<a href="#/search/js/page/1">Search</a>
<!-- Programmatic navigation -->
<button @click=${() => $.router.go('/about')}>
Go to About
</button>
<button @click=${() => $.router.go('contact')}>
Go to Contact (auto-adds leading slash)
</button>
</nav>
`;
```
## 🚀 Advanced Examples
### Complete Application with Layout
```javascript
// App.js
import { $, html } from 'sigpro';
import HomePage from './pages/Home.js';
import AboutPage from './pages/About.js';
import UserPage from './pages/User.js';
import SettingsPage from './pages/Settings.js';
import NotFound from './pages/NotFound.js';
// Layout component with navigation
const Layout = (content) => html`
<div class="app">
<header class="header">
<h1>My SigPro App</h1>
<nav class="nav">
<a href="#/" class:active=${() => isActive('/')}>Home</a>
<a href="#/about" class:active=${() => isActive('/about')}>About</a>
<a href="#/user/42" class:active=${() => isActive('/user/42')}>Profile</a>
<a href="#/settings" class:active=${() => isActive('/settings')}>Settings</a>
</nav>
</header>
<main class="main">
${content}
</main>
<footer class="footer">
<p>© 2024 SigPro App</p>
</footer>
</div>
`;
// Helper to check active route
const isActive = (path) => {
const current = window.location.hash.replace(/^#/, '') || '/';
return current === path;
};
// Routes with layout
const routes = [
{ path: '/', component: (params) => Layout(HomePage(params)) },
{ path: '/about', component: (params) => Layout(AboutPage(params)) },
{ path: '/user/:id', component: (params) => Layout(UserPage(params)) },
{ path: '/settings', component: (params) => Layout(SettingsPage(params)) },
{ path: '/:path(.*)', component: (params) => Layout(NotFound(params)) }, // Catch-all
];
// Create and mount router
const router = $.router(routes);
document.body.appendChild(router);
```
### Nested Routes
```javascript
// pages/Settings.js (parent route)
import { $, html } from 'sigpro';
import SettingsGeneral from './settings/General.js';
import SettingsSecurity from './settings/Security.js';
import SettingsNotifications from './settings/Notifications.js';
export default (params) => $.page(() => {
const section = params.section || 'general';
const sections = {
general: SettingsGeneral,
security: SettingsSecurity,
notifications: SettingsNotifications
};
const CurrentSection = sections[section];
return html`
<div class="settings">
<h1>Settings</h1>
<div class="settings-layout">
<nav class="settings-sidebar">
<a href="#/settings/general" class:active=${() => section === 'general'}>
General
</a>
<a href="#/settings/security" class:active=${() => section === 'security'}>
Security
</a>
<a href="#/settings/notifications" class:active=${() => section === 'notifications'}>
Notifications
</a>
</nav>
<div class="settings-content">
${CurrentSection(params)}
</div>
</div>
</div>
`;
});
// pages/settings/General.js
export default (params) => $.page(() => {
return html`
<div>
<h2>General Settings</h2>
<form>...</form>
</div>
`;
});
// Main router with nested routes
const routes = [
{ path: '/', component: HomePage },
{ path: '/settings/:section?', component: SettingsPage }, // Optional section param
];
```
### Protected Routes (Authentication)
```javascript
// auth.js
import { $ } from 'sigpro';
const isAuthenticated = $(false);
const user = $(null);
export const checkAuth = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
const response = await fetch('/api/verify');
if (response.ok) {
const userData = await response.json();
user(userData);
isAuthenticated(true);
return true;
}
} catch (e) {
// Handle error
}
}
isAuthenticated(false);
user(null);
return false;
};
export const requireAuth = (component) => (params) => {
if (isAuthenticated()) {
return component(params);
}
// Redirect to login
$.router.go('/login');
return null;
};
export { isAuthenticated, user };
```
```javascript
// pages/Dashboard.js (protected route)
import { $, html } from 'sigpro';
import { requireAuth, user } from '../auth.js';
const Dashboard = (params) => $.page(() => {
return html`
<div class="dashboard">
<h1>Welcome, ${() => user()?.name}!</h1>
<p>This is your protected dashboard.</p>
</div>
`;
});
export default requireAuth(Dashboard);
```
```javascript
// main.js with protected routes
import { $, html } from 'sigpro';
import { checkAuth } from './auth.js';
import HomePage from './pages/Home.js';
import LoginPage from './pages/Login.js';
import DashboardPage from './pages/Dashboard.js';
import AdminPage from './pages/Admin.js';
// Check auth on startup
checkAuth();
const routes = [
{ path: '/', component: HomePage },
{ path: '/login', component: LoginPage },
{ path: '/dashboard', component: DashboardPage }, // Protected
{ path: '/admin', component: AdminPage }, // Protected
];
document.body.appendChild($.router(routes));
```
### Route Transitions
```javascript
// with-transitions.js
import { $, html } from 'sigpro';
export const createRouterWithTransitions = (routes) => {
const transitioning = $(false);
const currentView = $(null);
const nextView = $(null);
const container = document.createElement('div');
container.style.display = 'contents';
const renderWithTransition = async (newView) => {
if (currentView() === newView) return;
transitioning(true);
nextView(newView);
// Fade out
container.style.transition = 'opacity 0.2s';
container.style.opacity = '0';
await new Promise(resolve => setTimeout(resolve, 200));
// Update content
container.replaceChildren(newView);
currentView(newView);
// Fade in
container.style.opacity = '1';
await new Promise(resolve => setTimeout(resolve, 200));
transitioning(false);
container.style.transition = '';
};
const router = $.router(routes.map(route => ({
...route,
component: (params) => {
const view = route.component(params);
renderWithTransition(view);
return document.createComment('router-placeholder');
}
})));
return router;
};
```
### Breadcrumbs Navigation
```javascript
// with-breadcrumbs.js
import { $, html } from 'sigpro';
export const createBreadcrumbs = (routes) => {
const breadcrumbs = $([]);
const updateBreadcrumbs = (path) => {
const parts = path.split('/').filter(Boolean);
const crumbs = [];
let currentPath = '';
parts.forEach((part, index) => {
currentPath += `/${part}`;
// Find matching route
const route = routes.find(r => {
if (r.path.includes(':')) {
const pattern = r.path.replace(/:[^/]+/g, part);
return pattern === currentPath;
}
return r.path === currentPath;
});
crumbs.push({
path: currentPath,
label: route?.name || part.charAt(0).toUpperCase() + part.slice(1),
isLast: index === parts.length - 1
});
});
breadcrumbs(crumbs);
};
// Listen to route changes
window.addEventListener('hashchange', () => {
const path = window.location.hash.replace(/^#/, '') || '/';
updateBreadcrumbs(path);
});
// Initial update
updateBreadcrumbs(window.location.hash.replace(/^#/, '') || '/');
return breadcrumbs;
};
```
```javascript
// Usage in layout
import { createBreadcrumbs } from './with-breadcrumbs.js';
const breadcrumbs = createBreadcrumbs(routes);
const Layout = (content) => html`
<div class="app">
<nav class="breadcrumbs">
${() => breadcrumbs().map(crumb => html`
${!crumb.isLast ? html`
<a href="#${crumb.path}">${crumb.label}</a>
<span class="separator">/</span>
` : html`
<span class="current">${crumb.label}</span>
`}
`)}
</nav>
<main>
${content}
</main>
</div>
`;
```
### Query Parameters
```javascript
// with-query-params.js
export const getQueryParams = () => {
const hash = window.location.hash;
const queryStart = hash.indexOf('?');
if (queryStart === -1) return {};
const queryString = hash.slice(queryStart + 1);
const params = new URLSearchParams(queryString);
const result = {};
for (const [key, value] of params) {
result[key] = value;
}
return result;
};
export const updateQueryParams = (params) => {
const hash = window.location.hash.split('?')[0];
const queryString = new URLSearchParams(params).toString();
window.location.hash = queryString ? `${hash}?${queryString}` : hash;
};
```
```javascript
// Search page with query params
import { $, html } from 'sigpro';
import { getQueryParams, updateQueryParams } from './with-query-params.js';
export default (params) => $.page(() => {
// Get initial query from URL
const queryParams = getQueryParams();
const searchQuery = $(queryParams.q || '');
const page = $(parseInt(queryParams.page) || 1);
const results = $([]);
// Update URL when search changes
$.effect(() => {
updateQueryParams({
q: searchQuery() || undefined,
page: page() > 1 ? page() : undefined
});
});
// Fetch results when search or page changes
$.effect(() => {
if (searchQuery()) {
fetch(`/api/search?q=${searchQuery()}&page=${page()}`)
.then(res => res.json())
.then(data => results(data));
}
});
return html`
<div class="search-page">
<h1>Search</h1>
<input
type="search"
:value=${searchQuery}
placeholder="Search..."
@input=${(e) => {
searchQuery(e.target.value);
page(1); // Reset to first page on new search
}}
/>
<div class="results">
${results().map(item => html`
<div class="result">${item.title}</div>
`)}
</div>
${() => results().length ? html`
<div class="pagination">
<button
?disabled=${() => page() <= 1}
@click=${() => page(p => p - 1)}
>
Previous
</button>
<span>Page ${page}</span>
<button
?disabled=${() => results().length < 10}
@click=${() => page(p => p + 1)}
>
Next
</button>
</div>
` : ''}
</div>
`;
});
```
### Lazy Loading Routes
```javascript
// lazy.js
export const lazy = (loader) => {
let component = null;
return async (params) => {
if (!component) {
const module = await loader();
component = module.default;
}
return component(params);
};
};
```
```javascript
// main.js with lazy loading
import { $, html } from 'sigpro';
import { lazy } from './lazy.js';
import Layout from './Layout.js';
const routes = [
{ path: '/', component: lazy(() => import('./pages/Home.js')) },
{ path: '/about', component: lazy(() => import('./pages/About.js')) },
{ path: '/dashboard', component: lazy(() => import('./pages/Dashboard.js')) },
{
path: '/admin',
component: lazy(() => import('./pages/Admin.js')),
// Show loading state
loading: () => html`<div class="loading">Loading admin panel...</div>`
},
];
// Wrap with layout
const routesWithLayout = routes.map(route => ({
...route,
component: (params) => Layout(route.component(params))
}));
document.body.appendChild($.router(routesWithLayout));
```
### Route Guards / Middleware
```javascript
// middleware.js
export const withGuard = (component, guard) => (params) => {
const result = guard(params);
if (result === true) {
return component(params);
} else if (typeof result === 'string') {
$.router.go(result);
return null;
}
return result; // Custom component (e.g., AccessDenied)
};
// Guards
export const roleGuard = (requiredRole) => (params) => {
const userRole = localStorage.getItem('userRole');
if (userRole === requiredRole) return true;
if (!userRole) return '/login';
return AccessDeniedPage(params);
};
export const authGuard = () => (params) => {
const token = localStorage.getItem('token');
return token ? true : '/login';
};
export const pendingChangesGuard = (hasPendingChanges) => (params) => {
if (hasPendingChanges()) {
return ConfirmLeavePage(params);
}
return true;
};
```
```javascript
// Usage
import { withGuard, authGuard, roleGuard } from './middleware.js';
const routes = [
{ path: '/', component: HomePage },
{ path: '/profile', component: withGuard(ProfilePage, authGuard()) },
{
path: '/admin',
component: withGuard(AdminPage, roleGuard('admin'))
},
];
```
## 📊 Route Matching Priority
Routes are matched in the order they are defined. More specific routes should come first:
```javascript
const routes = [
// More specific first
{ path: '/user/:id/edit', component: EditUserPage },
{ path: '/user/:id/posts', component: UserPostsPage },
{ path: '/user/:id', component: UserPage },
// Static routes
{ path: '/about', component: AboutPage },
{ path: '/contact', component: ContactPage },
// Catch-all last
{ path: '/:path(.*)', component: NotFoundPage },
];
```
## 🎯 Complete Example
```javascript
// main.js - Complete application
import { $, html } from 'sigpro';
import { lazy } from './utils/lazy.js';
import { withGuard, authGuard } from './utils/middleware.js';
import Layout from './components/Layout.js';
// Lazy load pages
const HomePage = lazy(() => import('./pages/Home.js'));
const AboutPage = lazy(() => import('./pages/About.js'));
const LoginPage = lazy(() => import('./pages/Login.js'));
const DashboardPage = lazy(() => import('./pages/Dashboard.js'));
const UserPage = lazy(() => import('./pages/User.js'));
const SettingsPage = lazy(() => import('./pages/Settings.js'));
const NotFoundPage = lazy(() => import('./pages/NotFound.js'));
// Route configuration
const routes = [
{ path: '/', component: HomePage, name: 'Home' },
{ path: '/about', component: AboutPage, name: 'About' },
{ path: '/login', component: LoginPage, name: 'Login' },
{
path: '/dashboard',
component: withGuard(DashboardPage, authGuard()),
name: 'Dashboard'
},
{
path: '/user/:id',
component: UserPage,
name: 'User Profile'
},
{
path: '/settings/:section?',
component: withGuard(SettingsPage, authGuard()),
name: 'Settings'
},
{ path: '/:path(.*)', component: NotFoundPage, name: 'Not Found' },
];
// Wrap all routes with layout
const routesWithLayout = routes.map(route => ({
...route,
component: (params) => Layout(route.component(params))
}));
// Create and mount router
const router = $.router(routesWithLayout);
document.body.appendChild(router);
// Navigation helper (available globally)
window.navigate = $.router.go;
```
## 📊 Summary
| Feature | Description |
|---------|-------------|
| **Hash-based** | Works everywhere, no server config |
| **Route Parameters** | `:param` syntax for dynamic segments |
| **RegExp Support** | Advanced pattern matching |
| **Query Parameters** | Support for `?key=value` in URLs |
| **Programmatic Navigation** | `$.router.go(path)` |
| **Auto-cleanup** | Works with `$.page` for memory management |
| **Zero Dependencies** | Pure vanilla JavaScript |
| **Lazy Loading Ready** | Easy code splitting |
---
> **Pro Tip:** Order matters in route definitions - put more specific routes (with parameters) before static ones, and always include a catch-all route (404) at the end.

683
docs/api/signals.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,899 +0,0 @@
# Signals API 📡
Signals are the heart of SigPro's reactivity system. They are reactive values that automatically track dependencies and notify subscribers when they change. This enables fine-grained updates without virtual DOM diffing.
## Core Concepts
### What is a Signal?
A signal is a function that holds a value and notifies dependents when that value changes. Signals can be:
- **Basic signals** - Hold simple values (numbers, strings, objects)
- **Computed signals** - Derive values from other signals
- **Persistent signals** - Automatically sync with localStorage/sessionStorage
### How Reactivity Works
SigPro uses automatic dependency tracking:
1. When you read a signal inside an effect, the effect becomes a subscriber
2. When the signal's value changes, all subscribers are notified
3. Updates are batched using microtasks for optimal performance
4. Only the exact nodes that depend on changed values are updated
## `$(initialValue)`
Creates a reactive signal. The behavior changes based on the type of `initialValue`:
- If `initialValue` is a **function**, creates a computed signal
- Otherwise, creates a basic signal
```javascript
import { $ } from 'sigpro';
// Basic signal
const count = $(0);
// Computed signal
const firstName = $('John');
const lastName = $('Doe');
const fullName = $(() => `${firstName()} ${lastName()}`);
```
## 📋 API Reference
### Basic Signals
| Pattern | Example | Description |
|---------|---------|-------------|
| Create | `const count = $(0)` | Create signal with initial value |
| Get | `count()` | Read current value |
| Set | `count(5)` | Set new value directly |
| Update | `count(prev => prev + 1)` | Update based on previous value |
### Computed Signals
| Pattern | Example | Description |
|---------|---------|-------------|
| Create | `const total = $(() => price() * quantity())` | Derive value from other signals |
| Get | `total()` | Read computed value (auto-updates) |
### Signal Methods
| Method | Description | Example |
|--------|-------------|---------|
| `signal()` | Gets current value | `count()` |
| `signal(newValue)` | Sets new value | `count(5)` |
| `signal(prev => new)` | Updates using previous value | `count(c => c + 1)` |
## 🎯 Basic Examples
### Counter Signal
```javascript
import { $ } from 'sigpro';
const count = $(0);
console.log(count()); // 0
count(5);
console.log(count()); // 5
count(prev => prev + 1);
console.log(count()); // 6
```
### Object Signal
```javascript
import { $ } from 'sigpro';
const user = $({
name: 'John',
age: 30,
email: 'john@example.com'
});
// Read
console.log(user().name); // 'John'
// Update (immutable pattern)
user({
...user(),
age: 31
});
// Partial update with function
user(prev => ({
...prev,
email: 'john.doe@example.com'
}));
```
### Array Signal
```javascript
import { $ } from 'sigpro';
const todos = $(['Learn SigPro', 'Build an app']);
// Add item
todos([...todos(), 'Deploy to production']);
// Remove item
todos(todos().filter((_, i) => i !== 1));
// Update item
todos(todos().map((todo, i) =>
i === 0 ? 'Master SigPro' : todo
));
```
## 🔄 Computed Signals
Computed signals automatically update when their dependencies change:
```javascript
import { $ } from 'sigpro';
const price = $(10);
const quantity = $(2);
const tax = $(0.21);
// Computed signals
const subtotal = $(() => price() * quantity());
const taxAmount = $(() => subtotal() * tax());
const total = $(() => subtotal() + taxAmount());
console.log(total()); // 24.2
price(15);
console.log(total()); // 36.3 (automatically updated)
quantity(3);
console.log(total()); // 54.45 (automatically updated)
```
### Computed with Multiple Dependencies
```javascript
import { $ } from 'sigpro';
const firstName = $('John');
const lastName = $('Doe');
const prefix = $('Mr.');
const fullName = $(() => {
// Computed signals can contain logic
const name = `${firstName()} ${lastName()}`;
return prefix() ? `${prefix()} ${name}` : name;
});
console.log(fullName()); // 'Mr. John Doe'
prefix('');
console.log(fullName()); // 'John Doe'
```
### Computed with Conditional Logic
```javascript
import { $ } from 'sigpro';
const user = $({ role: 'admin', permissions: [] });
const isAdmin = $(() => user().role === 'admin');
const hasPermission = $(() =>
isAdmin() || user().permissions.includes('edit')
);
console.log(hasPermission()); // true
user({ role: 'user', permissions: ['view'] });
console.log(hasPermission()); // false (can't edit)
user({ role: 'user', permissions: ['view', 'edit'] });
console.log(hasPermission()); // true (now has permission)
```
## 🧮 Advanced Signal Patterns
### Derived State Pattern
```javascript
import { $ } from 'sigpro';
// Shopping cart example
const cart = $([
{ id: 1, name: 'Product 1', price: 10, quantity: 2 },
{ id: 2, name: 'Product 2', price: 15, quantity: 1 },
]);
// Derived values
const itemCount = $(() =>
cart().reduce((sum, item) => sum + item.quantity, 0)
);
const subtotal = $(() =>
cart().reduce((sum, item) => sum + (item.price * item.quantity), 0)
);
const tax = $(() => subtotal() * 0.21);
const total = $(() => subtotal() + tax());
// Update cart
cart([
...cart(),
{ id: 3, name: 'Product 3', price: 20, quantity: 1 }
]);
// All derived values auto-update
console.log(itemCount()); // 4
console.log(total()); // (10*2 + 15*1 + 20*1) * 1.21 = 78.65
```
### Validation Pattern
```javascript
import { $ } from 'sigpro';
const email = $('');
const password = $('');
const confirmPassword = $('');
// Validation signals
const isEmailValid = $(() => {
const value = email();
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
});
const isPasswordValid = $(() => {
const value = password();
return value.length >= 8;
});
const doPasswordsMatch = $(() =>
password() === confirmPassword()
);
const isFormValid = $(() =>
isEmailValid() && isPasswordValid() && doPasswordsMatch()
);
// Update form
email('user@example.com');
password('secure123');
confirmPassword('secure123');
console.log(isFormValid()); // true
// Validation messages
const emailError = $(() =>
email() && !isEmailValid() ? 'Invalid email format' : ''
);
```
### Filtering and Search Pattern
```javascript
import { $ } from 'sigpro';
const items = $([
{ id: 1, name: 'Apple', category: 'fruit' },
{ id: 2, name: 'Banana', category: 'fruit' },
{ id: 3, name: 'Carrot', category: 'vegetable' },
{ id: 4, name: 'Date', category: 'fruit' },
]);
const searchTerm = $('');
const categoryFilter = $('all');
// Filtered items (computed)
const filteredItems = $(() => {
let result = items();
// Apply search filter
if (searchTerm()) {
const term = searchTerm().toLowerCase();
result = result.filter(item =>
item.name.toLowerCase().includes(term)
);
}
// Apply category filter
if (categoryFilter() !== 'all') {
result = result.filter(item =>
item.category === categoryFilter()
);
}
return result;
});
// Stats
const fruitCount = $(() =>
items().filter(item => item.category === 'fruit').length
);
const vegCount = $(() =>
items().filter(item => item.category === 'vegetable').length
);
// Update filters
searchTerm('a');
console.log(filteredItems().map(i => i.name)); // ['Apple', 'Banana', 'Carrot', 'Date']
categoryFilter('fruit');
console.log(filteredItems().map(i => i.name)); // ['Apple', 'Banana', 'Date']
```
### Pagination Pattern
```javascript
import { $ } from 'sigpro';
const allItems = $([...Array(100).keys()].map(i => `Item ${i + 1}`));
const currentPage = $(1);
const itemsPerPage = $(10);
// Paginated items (computed)
const paginatedItems = $(() => {
const start = (currentPage() - 1) * itemsPerPage();
const end = start + itemsPerPage();
return allItems().slice(start, end);
});
// Pagination metadata
const totalPages = $(() =>
Math.ceil(allItems().length / itemsPerPage())
);
const hasNextPage = $(() =>
currentPage() < totalPages()
);
const hasPrevPage = $(() =>
currentPage() > 1
);
const pageRange = $(() => {
const current = currentPage();
const total = totalPages();
const delta = 2;
let range = [];
for (let i = Math.max(2, current - delta);
i <= Math.min(total - 1, current + delta);
i++) {
range.push(i);
}
if (current - delta > 2) range = ['...', ...range];
if (current + delta < total - 1) range = [...range, '...'];
return [1, ...range, total];
});
// Navigation
const nextPage = () => {
if (hasNextPage()) currentPage(c => c + 1);
};
const prevPage = () => {
if (hasPrevPage()) currentPage(c => c - 1);
};
const goToPage = (page) => {
if (page >= 1 && page <= totalPages()) {
currentPage(page);
}
};
```
## 🔧 Advanced Signal Features
### Signal Equality Comparison
Signals use `Object.is` for change detection. Only notify subscribers when values are actually different:
```javascript
import { $ } from 'sigpro';
const count = $(0);
// These won't trigger updates:
count(0); // Same value
count(prev => prev); // Returns same value
// These will trigger updates:
count(1); // Different value
count(prev => prev + 0); // Still 0? Actually returns 0? Wait...
// Be careful with functional updates!
```
### Batch Updates
Multiple signal updates are batched into a single microtask:
```javascript
import { $ } from 'sigpro';
const firstName = $('John');
const lastName = $('Doe');
const fullName = $(() => `${firstName()} ${lastName()}`);
$.effect(() => {
console.log('Full name:', fullName());
});
// Logs: 'Full name: John Doe'
// Multiple updates in same tick - only one effect run!
firstName('Jane');
lastName('Smith');
// Only logs once: 'Full name: Jane Smith'
```
### Infinite Loop Protection
SigPro includes protection against infinite reactive loops:
```javascript
import { $ } from 'sigpro';
const a = $(1);
const b = $(2);
// This would create a loop, but SigPro prevents it
$.effect(() => {
a(b()); // Reading b
b(a()); // Reading a - loop detected!
});
// Throws: "SigPro: Infinite reactive loop detected."
```
## 📊 Performance Characteristics
| Operation | Complexity | Notes |
|-----------|------------|-------|
| Signal read | O(1) | Direct value access |
| Signal write | O(n) | n = number of subscribers |
| Computed read | O(1) or O(m) | m = computation complexity |
| Effect run | O(s) | s = number of signal reads |
## 🎯 Best Practices
### 1. Keep Signals Focused
```javascript
// ❌ Avoid large monolithic signals
const state = $({
user: null,
posts: [],
theme: 'light',
notifications: []
});
// ✅ Split into focused signals
const user = $(null);
const posts = $([]);
const theme = $('light');
const notifications = $([]);
```
### 2. Use Computed for Derived State
```javascript
// ❌ Don't compute in templates/effects
$.effect(() => {
const total = items().reduce((sum, i) => sum + i.price, 0);
updateUI(total);
});
// ✅ Compute with signals
const total = $(() => items().reduce((sum, i) => sum + i.price, 0));
$.effect(() => updateUI(total()));
```
### 3. Immutable Updates
```javascript
// ❌ Don't mutate objects/arrays
const user = $({ name: 'John' });
user().name = 'Jane'; // Won't trigger updates!
// ✅ Create new objects/arrays
user({ ...user(), name: 'Jane' });
// ❌ Don't mutate arrays
const todos = $(['a', 'b']);
todos().push('c'); // Won't trigger updates!
// ✅ Create new arrays
todos([...todos(), 'c']);
```
### 4. Functional Updates for Dependencies
```javascript
// ❌ Avoid if new value depends on current
count(count() + 1);
// ✅ Use functional update
count(prev => prev + 1);
```
### 5. Clean Up Effects
```javascript
import { $ } from 'sigpro';
const userId = $(1);
// Effects auto-clean in pages, but you can stop manually
const stop = $.effect(() => {
fetchUser(userId());
});
// Later, if needed
stop();
```
## 🚀 Real-World Examples
### Form State Management
```javascript
import { $ } from 'sigpro';
// Form state
const formData = $({
username: '',
email: '',
age: '',
newsletter: false
});
// Touched fields (for validation UI)
const touched = $({
username: false,
email: false,
age: false
});
// Validation rules
const validations = {
username: (value) =>
value.length >= 3 ? null : 'Username must be at least 3 characters',
email: (value) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'Invalid email',
age: (value) =>
!value || (value >= 18 && value <= 120) ? null : 'Age must be 18-120'
};
// Validation signals
const errors = $(() => {
const data = formData();
const result = {};
Object.keys(validations).forEach(field => {
const error = validations[field](data[field]);
if (error) result[field] = error;
});
return result;
});
const isValid = $(() => Object.keys(errors()).length === 0);
// Field helpers
const fieldProps = (field) => ({
value: formData()[field],
error: touched()[field] ? errors()[field] : null,
onChange: (e) => {
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
formData({
...formData(),
[field]: value
});
},
onBlur: () => {
touched({
...touched(),
[field]: true
});
}
});
// Form submission
const submitAttempts = $(0);
const isSubmitting = $(false);
const handleSubmit = async () => {
submitAttempts(s => s + 1);
if (!isValid()) {
// Mark all fields as touched to show errors
touched(Object.keys(formData()).reduce((acc, field) => ({
...acc,
[field]: true
}), {}));
return;
}
isSubmitting(true);
try {
await saveForm(formData());
// Reset form on success
formData({ username: '', email: '', age: '', newsletter: false });
touched({ username: false, email: false, age: false });
} finally {
isSubmitting(false);
}
};
```
### Todo App with Filters
```javascript
import { $ } from 'sigpro';
// State
const todos = $([
{ id: 1, text: 'Learn SigPro', completed: true },
{ id: 2, text: 'Build an app', completed: false },
{ id: 3, text: 'Write docs', completed: false }
]);
const filter = $('all'); // 'all', 'active', 'completed'
const newTodoText = $('');
// Computed values
const filteredTodos = $(() => {
const all = todos();
switch(filter()) {
case 'active':
return all.filter(t => !t.completed);
case 'completed':
return all.filter(t => t.completed);
default:
return all;
}
});
const activeCount = $(() =>
todos().filter(t => !t.completed).length
);
const completedCount = $(() =>
todos().filter(t => t.completed).length
);
const hasCompleted = $(() => completedCount() > 0);
// Actions
const addTodo = () => {
const text = newTodoText().trim();
if (text) {
todos([
...todos(),
{
id: Date.now(),
text,
completed: false
}
]);
newTodoText('');
}
};
const toggleTodo = (id) => {
todos(todos().map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
const deleteTodo = (id) => {
todos(todos().filter(todo => todo.id !== id));
};
const clearCompleted = () => {
todos(todos().filter(todo => !todo.completed));
};
const toggleAll = () => {
const allCompleted = activeCount() === 0;
todos(todos().map(todo => ({
...todo,
completed: !allCompleted
})));
};
```
### Shopping Cart
```javascript
import { $ } from 'sigpro';
// Products catalog
const products = $([
{ id: 1, name: 'Laptop', price: 999, stock: 5 },
{ id: 2, name: 'Mouse', price: 29, stock: 20 },
{ id: 3, name: 'Keyboard', price: 79, stock: 10 },
{ id: 4, name: 'Monitor', price: 299, stock: 3 }
]);
// Cart state
const cart = $({});
const selectedProduct = $(null);
const quantity = $(1);
// Computed cart values
const cartItems = $(() => {
const items = [];
Object.entries(cart()).forEach(([productId, qty]) => {
const product = products().find(p => p.id === parseInt(productId));
if (product) {
items.push({
...product,
quantity: qty,
subtotal: product.price * qty
});
}
});
return items;
});
const itemCount = $(() =>
cartItems().reduce((sum, item) => sum + item.quantity, 0)
);
const subtotal = $(() =>
cartItems().reduce((sum, item) => sum + item.subtotal, 0)
);
const tax = $(() => subtotal() * 0.10);
const shipping = $(() => subtotal() > 100 ? 0 : 10);
const total = $(() => subtotal() + tax() + shipping());
const isCartEmpty = $(() => itemCount() === 0);
// Cart actions
const addToCart = (product, qty = 1) => {
const currentQty = cart()[product.id] || 0;
const newQty = currentQty + qty;
if (newQty <= product.stock) {
cart({
...cart(),
[product.id]: newQty
});
return true;
}
return false;
};
const updateQuantity = (productId, newQty) => {
const product = products().find(p => p.id === productId);
if (newQty <= product.stock) {
if (newQty <= 0) {
removeFromCart(productId);
} else {
cart({
...cart(),
[productId]: newQty
});
}
}
};
const removeFromCart = (productId) => {
const newCart = { ...cart() };
delete newCart[productId];
cart(newCart);
};
const clearCart = () => cart({});
// Stock management
const productStock = (productId) => {
const product = products().find(p => p.id === productId);
if (!product) return 0;
const inCart = cart()[productId] || 0;
return product.stock - inCart;
};
const isInStock = (productId, qty = 1) => {
return productStock(productId) >= qty;
};
```
## 📈 Debugging Signals
### Logging Signal Changes
```javascript
import { $ } from 'sigpro';
// Wrap a signal to log changes
const withLogging = (signal, name) => {
return (...args) => {
if (args.length) {
const oldValue = signal();
const result = signal(...args);
console.log(`${name}:`, oldValue, '->', signal());
return result;
}
return signal();
};
};
// Usage
const count = withLogging($(0), 'count');
count(5); // Logs: "count: 0 -> 5"
```
### Signal Inspector
```javascript
import { $ } from 'sigpro';
// Create an inspectable signal
const createInspector = () => {
const signals = new Map();
const createSignal = (initialValue, name) => {
const signal = $(initialValue);
signals.set(signal, { name, subscribers: new Set() });
// Wrap to track subscribers
const wrapped = (...args) => {
if (!args.length && activeEffect) {
const info = signals.get(wrapped);
info.subscribers.add(activeEffect);
}
return signal(...args);
};
return wrapped;
};
const getInfo = () => {
const info = {};
signals.forEach((data, signal) => {
info[data.name] = {
subscribers: data.subscribers.size,
value: signal()
};
});
return info;
};
return { createSignal, getInfo };
};
// Usage
const inspector = createInspector();
const count = inspector.createSignal(0, 'count');
const doubled = inspector.createSignal(() => count() * 2, 'doubled');
console.log(inspector.getInfo());
// { count: { subscribers: 0, value: 0 }, doubled: { subscribers: 0, value: 0 } }
```
## 📊 Summary
| Feature | Description |
|---------|-------------|
| **Basic Signals** | Hold values and notify on change |
| **Computed Signals** | Auto-updating derived values |
| **Automatic Tracking** | Dependencies tracked automatically |
| **Batch Updates** | Multiple updates batched in microtask |
| **Infinite Loop Protection** | Prevents reactive cycles |
| **Zero Dependencies** | Pure vanilla JavaScript |
---
> **Pro Tip:** Signals are the foundation of reactivity in SigPro. Master them, and you've mastered 80% of the library!

820
docs/api/storage.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,952 +0,0 @@
# Storage API 💾
SigPro provides persistent signals that automatically synchronize with browser storage APIs. This allows you to create reactive state that survives page reloads and browser sessions with zero additional code.
## Core Concepts
### What is Persistent Storage?
Persistent signals are special signals that:
- **Initialize from storage** (localStorage/sessionStorage) if a saved value exists
- **Auto-save** whenever the signal value changes
- **Handle JSON serialization** automatically
- **Clean up** when set to `null` or `undefined`
### Storage Types
| Storage | Persistence | Use Case |
|---------|-------------|----------|
| `localStorage` | Forever (until cleared) | User preferences, themes, saved data |
| `sessionStorage` | Until tab/window closes | Form drafts, temporary state |
## `$.storage(key, initialValue, [storage])`
Creates a persistent signal that syncs with browser storage.
```javascript
import { $ } from 'sigpro';
// localStorage (default)
const theme = $.storage('theme', 'light');
const user = $.storage('user', null);
const settings = $.storage('settings', { notifications: true });
// sessionStorage
const draft = $.storage('draft', '', sessionStorage);
const formData = $.storage('form', {}, sessionStorage);
```
## 📋 API Reference
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `key` | `string` | required | Storage key name |
| `initialValue` | `any` | required | Default value if none stored |
| `storage` | `Storage` | `localStorage` | Storage type (`localStorage` or `sessionStorage`) |
### Returns
| Return | Description |
|--------|-------------|
| `Function` | Signal function (getter/setter) with persistence |
## 🎯 Basic Examples
### Theme Preference
```javascript
import { $, html } from 'sigpro';
// Persistent theme signal
const theme = $.storage('theme', 'light');
// Apply theme to document
$.effect(() => {
document.body.className = `theme-${theme()}`;
});
// Toggle theme
const toggleTheme = () => {
theme(t => t === 'light' ? 'dark' : 'light');
};
// Template
html`
<div>
<p>Current theme: ${theme}</p>
<button @click=${toggleTheme}>
Toggle Theme
</button>
</div>
`;
```
### User Preferences
```javascript
import { $ } from 'sigpro';
// Complex preferences object
const preferences = $.storage('preferences', {
language: 'en',
fontSize: 'medium',
notifications: true,
compactView: false,
sidebarOpen: true
});
// Update single preference
const setPreference = (key, value) => {
preferences({
...preferences(),
[key]: value
});
};
// Usage
setPreference('language', 'es');
setPreference('fontSize', 'large');
console.log(preferences().language); // 'es'
```
### Form Draft
```javascript
import { $, html } from 'sigpro';
// Session-based draft (clears when tab closes)
const draft = $.storage('contact-form', {
name: '',
email: '',
message: ''
}, sessionStorage);
// Auto-save on input
const handleInput = (field, value) => {
draft({
...draft(),
[field]: value
});
};
// Clear draft after submit
const handleSubmit = async () => {
await submitForm(draft());
draft(null); // Clears from storage
};
// Template
html`
<form @submit=${handleSubmit}>
<input
type="text"
:value=${() => draft().name}
@input=${(e) => handleInput('name', e.target.value)}
placeholder="Name"
/>
<input
type="email"
:value=${() => draft().email}
@input=${(e) => handleInput('email', e.target.value)}
placeholder="Email"
/>
<textarea
:value=${() => draft().message}
@input=${(e) => handleInput('message', e.target.value)}
placeholder="Message"
></textarea>
<button type="submit">Send</button>
</form>
`;
```
## 🚀 Advanced Examples
### Authentication State
```javascript
import { $, html } from 'sigpro';
// Persistent auth state
const auth = $.storage('auth', {
token: null,
user: null,
expiresAt: null
});
// Computed helpers
const isAuthenticated = $(() => {
const { token, expiresAt } = auth();
if (!token || !expiresAt) return false;
return new Date(expiresAt) > new Date();
});
const user = $(() => auth().user);
// Login function
const login = async (email, password) => {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
if (response.ok) {
const { token, user, expiresIn } = await response.json();
auth({
token,
user,
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString()
});
return true;
}
return false;
};
// Logout
const logout = () => {
auth(null); // Clear from storage
};
// Auto-refresh token
$.effect(() => {
if (!isAuthenticated()) return;
const { expiresAt } = auth();
const expiresIn = new Date(expiresAt) - new Date();
const refreshTime = expiresIn - 60000; // 1 minute before expiry
if (refreshTime > 0) {
const timer = setTimeout(refreshToken, refreshTime);
return () => clearTimeout(timer);
}
});
// Navigation guard
$.effect(() => {
if (!isAuthenticated() && window.location.pathname !== '/login') {
$.router.go('/login');
}
});
```
### Multi-tab Synchronization
```javascript
import { $ } from 'sigpro';
// Storage key for cross-tab communication
const STORAGE_KEY = 'app-state';
// Create persistent signal
const appState = $.storage(STORAGE_KEY, {
count: 0,
lastUpdated: null
});
// Listen for storage events (changes from other tabs)
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_KEY && event.newValue) {
try {
// Update signal without triggering save loop
const newValue = JSON.parse(event.newValue);
appState(newValue);
} catch (e) {
console.error('Failed to parse storage event:', e);
}
}
});
// Update state (syncs across all tabs)
const increment = () => {
appState({
count: appState().count + 1,
lastUpdated: new Date().toISOString()
});
};
// Tab counter
const tabCount = $(1);
// Track number of tabs open
window.addEventListener('storage', (event) => {
if (event.key === 'tab-heartbeat') {
tabCount(parseInt(event.newValue) || 1);
}
});
// Send heartbeat
setInterval(() => {
localStorage.setItem('tab-heartbeat', tabCount());
}, 1000);
```
### Settings Manager
```javascript
import { $, html } from 'sigpro';
// Settings schema
const settingsSchema = {
theme: {
type: 'select',
options: ['light', 'dark', 'system'],
default: 'system'
},
fontSize: {
type: 'range',
min: 12,
max: 24,
default: 16
},
notifications: {
type: 'checkbox',
default: true
},
language: {
type: 'select',
options: ['en', 'es', 'fr', 'de'],
default: 'en'
}
};
// Persistent settings
const settings = $.storage('app-settings',
Object.entries(settingsSchema).reduce((acc, [key, config]) => ({
...acc,
[key]: config.default
}), {})
);
// Settings component
const SettingsPanel = () => {
return html`
<div class="settings-panel">
<h2>Settings</h2>
${Object.entries(settingsSchema).map(([key, config]) => {
switch(config.type) {
case 'select':
return html`
<div class="setting">
<label>${key}:</label>
<select
:value=${() => settings()[key]}
@change=${(e) => updateSetting(key, e.target.value)}
>
${config.options.map(opt => html`
<option value="${opt}" ?selected=${() => settings()[key] === opt}>
${opt}
</option>
`)}
</select>
</div>
`;
case 'range':
return html`
<div class="setting">
<label>${key}: ${() => settings()[key]}</label>
<input
type="range"
min="${config.min}"
max="${config.max}"
:value=${() => settings()[key]}
@input=${(e) => updateSetting(key, parseInt(e.target.value))}
/>
</div>
`;
case 'checkbox':
return html`
<div class="setting">
<label>
<input
type="checkbox"
:checked=${() => settings()[key]}
@change=${(e) => updateSetting(key, e.target.checked)}
/>
${key}
</label>
</div>
`;
}
})}
<button @click=${resetDefaults}>Reset to Defaults</button>
</div>
`;
};
// Helper functions
const updateSetting = (key, value) => {
settings({
...settings(),
[key]: value
});
};
const resetDefaults = () => {
const defaults = Object.entries(settingsSchema).reduce((acc, [key, config]) => ({
...acc,
[key]: config.default
}), {});
settings(defaults);
};
// Apply settings globally
$.effect(() => {
const { theme, fontSize } = settings();
// Apply theme
document.documentElement.setAttribute('data-theme', theme);
// Apply font size
document.documentElement.style.fontSize = `${fontSize}px`;
});
```
### Shopping Cart Persistence
```javascript
import { $, html } from 'sigpro';
// Persistent shopping cart
const cart = $.storage('shopping-cart', {
items: [],
lastUpdated: null
});
// Computed values
const cartItems = $(() => cart().items);
const itemCount = $(() => cartItems().reduce((sum, item) => sum + item.quantity, 0));
const subtotal = $(() => cartItems().reduce((sum, item) => sum + (item.price * item.quantity), 0));
const tax = $(() => subtotal() * 0.1);
const total = $(() => subtotal() + tax());
// Cart actions
const addToCart = (product, quantity = 1) => {
const existing = cartItems().findIndex(item => item.id === product.id);
if (existing >= 0) {
// Update quantity
const newItems = [...cartItems()];
newItems[existing] = {
...newItems[existing],
quantity: newItems[existing].quantity + quantity
};
cart({
items: newItems,
lastUpdated: new Date().toISOString()
});
} else {
// Add new item
cart({
items: [...cartItems(), { ...product, quantity }],
lastUpdated: new Date().toISOString()
});
}
};
const removeFromCart = (productId) => {
cart({
items: cartItems().filter(item => item.id !== productId),
lastUpdated: new Date().toISOString()
});
};
const updateQuantity = (productId, quantity) => {
if (quantity <= 0) {
removeFromCart(productId);
} else {
const newItems = cartItems().map(item =>
item.id === productId ? { ...item, quantity } : item
);
cart({
items: newItems,
lastUpdated: new Date().toISOString()
});
}
};
const clearCart = () => {
cart({
items: [],
lastUpdated: new Date().toISOString()
});
};
// Cart expiration (7 days)
const CART_EXPIRY_DAYS = 7;
$.effect(() => {
const lastUpdated = cart().lastUpdated;
if (lastUpdated) {
const expiryDate = new Date(lastUpdated);
expiryDate.setDate(expiryDate.getDate() + CART_EXPIRY_DAYS);
if (new Date() > expiryDate) {
clearCart();
}
}
});
// Cart display component
const CartDisplay = () => html`
<div class="cart">
<h3>Shopping Cart (${itemCount} items)</h3>
${cartItems().map(item => html`
<div class="cart-item">
<span>${item.name}</span>
<span>$${item.price} x ${item.quantity}</span>
<span>$${item.price * item.quantity}</span>
<button @click=${() => removeFromCart(item.id)}>Remove</button>
<input
type="number"
min="1"
:value=${item.quantity}
@change=${(e) => updateQuantity(item.id, parseInt(e.target.value))}
/>
</div>
`)}
<div class="cart-totals">
<p>Subtotal: $${subtotal}</p>
<p>Tax (10%): $${tax}</p>
<p><strong>Total: $${total}</strong></p>
</div>
${() => cartItems().length > 0 ? html`
<button @click=${checkout}>Checkout</button>
<button @click=${clearCart}>Clear Cart</button>
` : html`
<p>Your cart is empty</p>
`}
</div>
`;
```
### Recent Searches History
```javascript
import { $, html } from 'sigpro';
// Persistent search history (max 10 items)
const searchHistory = $.storage('search-history', []);
// Add search to history
const addSearch = (query) => {
if (!query.trim()) return;
const current = searchHistory();
const newHistory = [
{ query, timestamp: new Date().toISOString() },
...current.filter(item => item.query !== query)
].slice(0, 10); // Keep only last 10
searchHistory(newHistory);
};
// Clear history
const clearHistory = () => {
searchHistory([]);
};
// Remove specific item
const removeFromHistory = (query) => {
searchHistory(searchHistory().filter(item => item.query !== query));
};
// Search component
const SearchWithHistory = () => {
const searchInput = $('');
const handleSearch = () => {
const query = searchInput();
if (query) {
addSearch(query);
performSearch(query);
searchInput('');
}
};
return html`
<div class="search-container">
<div class="search-box">
<input
type="search"
:value=${searchInput}
@keydown.enter=${handleSearch}
placeholder="Search..."
/>
<button @click=${handleSearch}>Search</button>
</div>
${() => searchHistory().length > 0 ? html`
<div class="search-history">
<h4>Recent Searches</h4>
${searchHistory().map(item => html`
<div class="history-item">
<button
class="history-query"
@click=${() => {
searchInput(item.query);
handleSearch();
}}
>
🔍 ${item.query}
</button>
<small>${new Date(item.timestamp).toLocaleString()}</small>
<button
class="remove-btn"
@click=${() => removeFromHistory(item.query)}
>
</button>
</div>
`)}
<button class="clear-btn" @click=${clearHistory}>
Clear History
</button>
</div>
` : ''}
</div>
`;
};
```
### Multiple Profiles / Accounts
```javascript
import { $, html } from 'sigpro';
// Profile manager
const profiles = $.storage('user-profiles', {
current: 'default',
list: {
default: {
name: 'Default',
theme: 'light',
preferences: {}
}
}
});
// Switch profile
const switchProfile = (profileId) => {
profiles({
...profiles(),
current: profileId
});
};
// Create profile
const createProfile = (name) => {
const id = `profile-${Date.now()}`;
profiles({
current: id,
list: {
...profiles().list,
[id]: {
name,
theme: 'light',
preferences: {},
createdAt: new Date().toISOString()
}
}
});
return id;
};
// Delete profile
const deleteProfile = (profileId) => {
if (profileId === 'default') return; // Can't delete default
const newList = { ...profiles().list };
delete newList[profileId];
profiles({
current: 'default',
list: newList
});
};
// Get current profile data
const currentProfile = $(() => {
const { current, list } = profiles();
return list[current] || list.default;
});
// Profile-aware settings
const profileTheme = $(() => currentProfile().theme);
const profilePreferences = $(() => currentProfile().preferences);
// Update profile data
const updateCurrentProfile = (updates) => {
const { current, list } = profiles();
profiles({
current,
list: {
...list,
[current]: {
...list[current],
...updates
}
}
});
};
// Profile selector component
const ProfileSelector = () => html`
<div class="profile-selector">
<select
:value=${() => profiles().current}
@change=${(e) => switchProfile(e.target.value)}
>
${Object.entries(profiles().list).map(([id, profile]) => html`
<option value="${id}">${profile.name}</option>
`)}
</select>
<button @click=${() => {
const name = prompt('Enter profile name:');
if (name) createProfile(name);
}}>
New Profile
</button>
</div>
`;
```
## 🛡️ Error Handling
### Storage Errors
```javascript
import { $ } from 'sigpro';
// Safe storage wrapper
const safeStorage = (key, initialValue, storage = localStorage) => {
try {
return $.storage(key, initialValue, storage);
} catch (error) {
console.warn(`Storage failed for ${key}, using in-memory fallback:`, error);
return $(initialValue);
}
};
// Usage with fallback
const theme = safeStorage('theme', 'light');
const user = safeStorage('user', null);
```
### Quota Exceeded Handling
```javascript
import { $ } from 'sigpro';
const createManagedStorage = (key, initialValue, maxSize = 1024 * 100) => { // 100KB limit
const signal = $.storage(key, initialValue);
// Monitor size
const size = $(0);
$.effect(() => {
try {
const value = signal();
const json = JSON.stringify(value);
const bytes = new Blob([json]).size;
size(bytes);
if (bytes > maxSize) {
console.warn(`Storage for ${key} exceeded ${maxSize} bytes`);
// Could implement cleanup strategy here
}
} catch (e) {
console.error('Size check failed:', e);
}
});
return { signal, size };
};
// Usage
const { signal: largeData, size } = createManagedStorage('app-data', {}, 50000);
```
## 📊 Storage Limits
| Storage Type | Typical Limit | Notes |
|--------------|---------------|-------|
| `localStorage` | 5-10MB | Varies by browser |
| `sessionStorage` | 5-10MB | Cleared when tab closes |
| `cookies` | 4KB | Not recommended for SigPro |
## 🎯 Best Practices
### 1. Validate Stored Data
```javascript
import { $ } from 'sigpro';
// Schema validation
const createValidatedStorage = (key, schema, defaultValue, storage) => {
const signal = $.storage(key, defaultValue, storage);
// Wrap to validate on read/write
const validated = (...args) => {
if (args.length) {
// Validate before writing
const value = args[0];
if (typeof value === 'function') {
// Handle functional updates
return validated(validated());
}
// Basic validation
const isValid = Object.keys(schema).every(key => {
const validator = schema[key];
return !validator || validator(value[key]);
});
if (!isValid) {
console.warn('Invalid data, skipping storage write');
return signal();
}
}
return signal(...args);
};
return validated;
};
// Usage
const userSchema = {
name: v => v && v.length > 0,
age: v => v >= 18 && v <= 120,
email: v => /@/.test(v)
};
const user = createValidatedStorage('user', userSchema, {
name: '',
age: 25,
email: ''
});
```
### 2. Handle Versioning
```javascript
import { $ } from 'sigpro';
const VERSION = 2;
const createVersionedStorage = (key, migrations, storage) => {
const raw = $.storage(key, { version: VERSION, data: {} }, storage);
const migrate = (data) => {
let current = data;
const currentVersion = current.version || 1;
for (let v = currentVersion; v < VERSION; v++) {
const migrator = migrations[v];
if (migrator) {
current = migrator(current);
}
}
return current;
};
// Migrate if needed
const stored = raw();
if (stored.version !== VERSION) {
const migrated = migrate(stored);
raw(migrated);
}
return raw;
};
// Usage
const migrations = {
1: (old) => ({
version: 2,
data: {
...old.data,
preferences: old.preferences || {}
}
})
};
const settings = createVersionedStorage('app-settings', migrations);
```
### 3. Encrypt Sensitive Data
```javascript
import { $ } from 'sigpro';
// Simple encryption (use proper crypto in production)
const encrypt = (text) => {
return btoa(text); // Base64 - NOT secure, just example
};
const decrypt = (text) => {
try {
return atob(text);
} catch {
return null;
}
};
const createSecureStorage = (key, initialValue, storage) => {
const encryptedKey = `enc_${key}`;
const signal = $.storage(encryptedKey, null, storage);
const secure = (...args) => {
if (args.length) {
// Encrypt before storing
const value = args[0];
const encrypted = encrypt(JSON.stringify(value));
return signal(encrypted);
}
// Decrypt when reading
const encrypted = signal();
if (!encrypted) return initialValue;
try {
const decrypted = decrypt(encrypted);
return decrypted ? JSON.parse(decrypted) : initialValue;
} catch {
return initialValue;
}
};
return secure;
};
// Usage
const secureToken = createSecureStorage('auth-token', null);
secureToken('sensitive-data-123'); // Stored encrypted
```
## 📈 Performance Considerations
| Operation | Cost | Notes |
|-----------|------|-------|
| Initial read | O(1) | Single storage read |
| Write | O(1) + JSON.stringify | Auto-save on change |
| Large objects | O(n) | Stringify/parse overhead |
| Multiple keys | O(k) | k = number of keys |
---
> **Pro Tip:** Use `sessionStorage` for temporary data like form drafts, and `localStorage` for persistent user preferences. Always validate data when reading from storage to handle corrupted values gracefully.