Docs
This commit is contained in:
760
docs/src/api/components.md
Normal file
760
docs/src/api/components.md
Normal file
@@ -0,0 +1,760 @@
|
||||
# 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}>×</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.
|
||||
1039
docs/src/api/effects.md
Normal file
1039
docs/src/api/effects.md
Normal file
File diff suppressed because it is too large
Load Diff
998
docs/src/api/fetch.md
Normal file
998
docs/src/api/fetch.md
Normal file
@@ -0,0 +1,998 @@
|
||||
# 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.
|
||||
497
docs/src/api/pages.md
Normal file
497
docs/src/api/pages.md
Normal file
@@ -0,0 +1,497 @@
|
||||
# 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.
|
||||
436
docs/src/api/quick.md
Normal file
436
docs/src/api/quick.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# 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>
|
||||
784
docs/src/api/routing.md
Normal file
784
docs/src/api/routing.md
Normal file
@@ -0,0 +1,784 @@
|
||||
# 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.
|
||||
899
docs/src/api/signals.md
Normal file
899
docs/src/api/signals.md
Normal file
@@ -0,0 +1,899 @@
|
||||
# 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!
|
||||
952
docs/src/api/storage.md
Normal file
952
docs/src/api/storage.md
Normal file
@@ -0,0 +1,952 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user