snice
v4.36.2
Published
A decorator-driven web component library with differential rendering, routing, controllers, and 130+ ready-made UI components. Use as much or as little as you want. Zero dependencies, works anywhere.
Downloads
955
Maintainers
Keywords
Readme
Snice
A decorator-driven web component library with differential rendering, routing, controllers, and 130+ ready-made UI components. Use as much or as little as you want. Zero dependencies, works anywhere.
Quick Start
npx snice create-app my-app
cd my-app
npm run devTemplates
# Default - routing, auth, guards, middleware, services
npx snice create-app my-app
# React + Snice - React router, hooks, guards, layouts
npx snice create-app my-app --template=reactWhat's in the Box
Basic Building Blocks
@page({ tag: 'user-profile-page', routes: ['/users/:userId'], guards: [isAuthenticated] })
class UserProfilePage extends HTMLElement { ... }
@element('user-stats')
class UserStats extends HTMLElement { ... }
@controller('real-time-user-loader')
class RealTimeUserLoader { ... }
// And within these classes, use decorators like:
@property() name = 'default';
@render() fn() { return html`...`; }
@styles() fn() { return css`...`; }
@ready() async fn() { ... }
@dispose() fn() { ... }
@watch('name') fn(oldVal, newVal) { ... }
@query('input') input!: HTMLInputElement;
@queryAll('.item') items!: NodeListOf<HTMLElement>;
@on('click', 'button') fn(e: Event) { ... }
@dispatch('value-changed') fn(val: string) => Event Detail
@context() fn(ctx: Context) { ... }
@request('user') fn(): () => Request;
@respond('user') fn(req) => Response;1. Cross-Cutting Concerns: Router + Context
// sample-app-context.ts
class AppContext {
user: User | null = null;
theme: 'light' | 'dark' = 'light';
setUser(user: User) { this.user = user; }
getUser() { return this.user; }
}
// main.ts
import { Router } from 'snice';
const { page, navigate, initialize } = Router({
target: '#app',
context: new AppContext(), // Global state
type: 'hash'
});
// Any page can access context
@page({ tag: 'dashboard-page', routes: ['/dashboard'] })
class DashboardPage extends HTMLElement {
private appContext?: AppContext;
@context()
handleContext(ctx: Context) {
this.appContext = ctx.application;
const user = this.getUser();
}
// ...
}2. Pages: Orchestrating Intent
// pages/user-profile-page.ts
@page({ tag: 'user-profile-page', routes: ['/users/:userId'] })
class UserProfilePage extends HTMLElement {
@property()
userId = ''; // From URL parameter
@property({ type: Object })
user = null;
@property({ type: Object })
userStats = null;
@ready()
async loadUserData() {
// Pages handle data fetching, elements just display
const [user, stats] = await Promise.all([
fetch(`/api/users/${this.userId}`).then(r => r.json()),
fetch(`/api/users/${this.userId}/stats`).then(r => r.json())
]);
this.user = user;
this.userStats = stats;
}
@render()
renderContent() {
return html`
<page-header .user=${this.user}></page-header>
<user-stats .stats=${this.userStats}></user-stats>
<user-activity .userId=${this.userId}></user-activity>
`;
}
}3. Elements: Pure Presentation
// elements/user-stats.ts
@element('user-stats')
class UserStats extends HTMLElement {
@property({ type: Object })
stats = null;
@render()
renderContent() {
if (!this.stats) return html`<div>Loading...</div>`;
return html`
<div class="stats">
<div class="stat">
<span class="label">Views</span>
<span class="value">${this.stats.views}</span>
</div>
<div class="stat">
<span class="label">Followers</span>
<span class="value">${this.stats.followers}</span>
</div>
</div>
`;
}
@styles()
statsStyles() {
return css`
.stats { display: flex; gap: 2rem; }
.stat { text-align: center; }
`;
}
}
// Usage in parent page (which handles data fetching):
// <user-stats .stats=${this.userStats}></user-stats>4. Controllers: Behavior Management
// controllers/real-time-user-loader.ts
@controller('real-time-user-loader')
class RealTimeUserLoader {
async attach(element: IUserList) {
this.socket = new WebSocket('/api/users/stream');
this.socket.onmessage = (e) => {
element.setUsers(JSON.parse(e.data));
};
}
// ...
}
// controllers/cached-user-loader.ts
@controller('cached-user-loader')
class CachedUserLoader {
async attach(element: IUserList) {
const cached = localStorage.getItem('users');
if (cached) element.setUsers(JSON.parse(cached));
}
// ...
}
// elements/user-list.ts - stays the same
@element('user-list')
class UserList extends HTMLElement {
setUsers(users: User[]) {
this.users = users;
// ...
}
@render()
renderContent() {
return html`
<ul>${this.users.map(u => html`<li>${u.name}</li>`)}</ul>
`;
}
}Usage - swap behavior without touching presentation:
<user-list controller="real-time-user-loader"></user-list>
<user-list controller="cached-user-loader"></user-list>Key Features
Differential Rendering - Only updates changed parts of the DOM, not entire components
Auto-Rendering - Components automatically re-render when properties change
Template Syntax - Clean html\...`andcss`...`` tagged templates
Type Safety - Full TypeScript support with decorator-based APIs
Zero Dependencies - No external runtime dependencies
Standards-Based - Built on web components, works with any library or framework
Core APIs
Class Decorators
@element('tag-name') - Define reusable UI components
@element('my-button')
class MyButton extends HTMLElement { }@page({ tag, routes }) - Define routable pages
@page({ tag: 'home-page', routes: ['/'] })
class HomePage extends HTMLElement { }@controller('controller-name') - Define behavior modules
@controller('data-loader')
class DataLoader {
async attach(element) { }
async detach(element) { }
}Rendering
@render(options?) - Define component template (auto re-renders on property changes)
@render()
renderContent() {
return html`<div>${this.data}</div>`;
}@styles() - Define scoped styles
@styles()
componentStyles() {
return css`.container { padding: 1rem; }`;
}Properties & State
@property(options?) - Reactive properties that sync with attributes
@property()
name = 'default';
@property({ type: Boolean })
enabled = false;@watch(...propertyNames) - React to property changes
@watch('name')
onNameChange(oldVal, newVal) {
console.log(`Name changed from ${oldVal} to ${newVal}`);
}Lifecycle
@ready() - Runs after initial render completes
@ready()
async initialize() {
// Fetch data, set up listeners, etc.
}@dispose() - Runs when element is removed from DOM
@dispose()
cleanup() {
// Clean up listeners, close connections, etc.
}DOM Queries
@query(selector) - Query single element from shadow DOM
@query('input')
input!: HTMLInputElement;@queryAll(selector) - Query multiple elements from shadow DOM
@queryAll('.item')
items!: NodeListOf<HTMLElement>;Events & Communication
Template Events - Handle events directly in templates (with keyboard modifiers!)
html`
<button @click=${this.handleClick}>Click</button>
<input @keydown:Enter=${this.submit} />
<input @keydown:ctrl+s=${this.save} />
`@on Decorator - Event delegation with selectors
// Works in both elements AND controllers
@on('click', 'button') // Event delegation
handleClick(e: Event) {
console.log('Button clicked!');
}
@on('keydown:Enter', 'input') // Keyboard modifiers
handleEnter(e: KeyboardEvent) {
this.submit();
}
@on('input', 'input', { debounce: 300 }) // Debounce support
handleInput(e: Event) {
this.search((e.target as HTMLInputElement).value);
}@dispatch(eventName) - Auto-dispatch custom events after method execution
@dispatch('value-changed')
setValue(val: string) {
this.value = val;
return { value: val }; // Event detail
}Global State
@context(options?) - Receive router context updates (global state)
// Method decorator that receives context updates
@context()
handleContext(ctx: Context) {
this.appContext = ctx.application;
this.requestRender();
}
// With timing options
@context({ debounce: 300 })
handleContextDebounced(ctx: Context) {
// Called after 300ms of no updates
}
@context({ throttle: 100 })
handleContextThrottled(ctx: Context) {
// Called at most once per 100ms
}
@context({ once: true })
handleContextOnce(ctx: Context) {
// Called only once, then unregisters
}Context Object Structure:
interface Context {
application: AppContext; // Your router context
navigation: {
placards: Placard[]; // Page metadata
route: string; // Current route
params: Record<string, string>; // Route parameters
};
update(): void; // Notify all subscribers
}Triggering Context Updates:
When you modify the application context, call update() to notify all subscribers:
@page({ tag: 'login-page', routes: ['/login'] })
class LoginPage extends HTMLElement {
private ctx?: Context;
@context()
handleContext(ctx: Context) {
this.ctx = ctx;
this.requestRender();
}
login(user: User) {
// Modify the application context
this.ctx!.application.setUser(user);
// Notify all @context subscribers
this.ctx!.update();
}
}Note: The router calls update() automatically during navigation. Only call it manually when you change application state (like login/logout, theme changes, etc.).
Request/Response
For the few cases where elements need to request data from controllers (like fetching user info or current state), Snice provides a request/response pattern:
@request(channel) - Make requests to controllers from elements
@respond(channel) - Respond to requests from elements in controllers
This pattern is useful when:
- Elements need to fetch data without direct controller access
- You want to keep elements decoupled from specific controller implementations
- Multiple elements may request the same data
Example:
// Controller responds to requests
@element('app-controller')
class AppController extends HTMLElement {
private currentUser = { name: 'Alice', role: 'admin' };
@respond('user')
getUserData() {
return this.currentUser;
}
}
// Element makes requests
@element('user-badge')
class UserBadge extends HTMLElement {
@request('user')
getUser!: () => any;
@ready()
init() {
const user = this.getUser();
console.log('Current user:', user);
}
@render()
renderContent() {
const user = this.getUser();
return html`<div>Welcome, ${user.name}!</div>`;
}
}Usage:
<app-controller>
<user-badge></user-badge>
</app-controller>See Request/Response documentation for details.
Template Syntax
Auto-Rendering with Differential Updates
@element('counter-display')
class CounterDisplay extends HTMLElement {
@property({ type: Number })
count = 0;
@render()
renderContent() {
return html`
<div class="counter">
<span class="count">${this.count}</span>
<button @click=${this.increment}>+</button>
</div>
`;
}
@styles()
counterStyles() {
return css`.counter { display: flex; gap: 1rem; }`;
}
increment() {
this.count++;
// Auto re-renders! Only <span class="count"> updates
}
}Key Points:
- Properties trigger automatic re-renders
- Only changed parts update (differential rendering)
- Event handlers:
@click=${this.method} - Batched updates (multiple changes = single render)
Property Binding
Use .property=${value} to set element properties directly:
html`
<input .value=${this.text} />
<custom-element .complexData=${this.dataObject}></custom-element>
`Boolean Attributes
Use ?attribute=${boolean} for boolean attributes:
html`
<button ?disabled=${this.isLoading}>Submit</button>
<input type="checkbox" ?checked=${this.isChecked} />
`Conditionals
// Ternary operator
html`
${this.isLoggedIn
? html`<span>Welcome!</span>`
: html`<a href="/login">Login</a>`
}
`
// <if> conditional element
html`
<if ${this.isLoggedIn}>
<span>Welcome, ${this.user.name}!</span>
<button @click=${this.logout}>Logout</button>
</if>
<if ${!this.isLoggedIn}>
<a href="/login">Login</a>
</if>
`
// <case>/<when>/<default> for multiple branches
html`
<case ${this.status}>
<when value="loading">
<span>Loading...</span>
</when>
<when value="success">
<span>Success!</span>
</when>
<when value="error">
<span>Error occurred</span>
</when>
<default>
<span>Unknown status</span>
</default>
</case>
`Lists
html`
<ul>
${this.items.map(item => html`
<li @click=${() => this.select(item.id)}>${item.name}</li>
`)}
</ul>
`Keyboard Shortcuts
html`
<input @keydown.enter=${this.submit} />
<input @keydown.ctrl+s=${this.save} />
<input @keydown.ctrl+shift+s=${this.saveAs} />
<input @keydown.escape=${this.cancel} />
<input @keydown.~enter=${this.submitAny} />
`Keyboard syntax:
@keydown.enter- Plain Enter (no modifiers)@keydown.ctrl+s- Ctrl+S combination@keydown.~enter- Enter with any modifiers@keydown.down- Arrow keys (up, down, left, right)@keydown.escape- Escape key
Router
// main.ts
const { page, navigate, initialize } = Router({
target: '#app',
context: new AppContext()
});
// pages/home-page.ts
@page({ tag: 'home-page', routes: ['/'] })
class HomePage extends HTMLElement {
@render()
renderContent() {
return html`<h1>Home</h1>`;
}
}
// pages/user-page.ts
@page({ tag: 'user-page', routes: ['/users/:userId'] })
class UserPage extends HTMLElement {
@property()
userId = ''; // Auto-populated from URL
// ...
}
// main.ts
initialize();
navigate('/users/123');Route Guards
Protect routes with guard functions:
const isAuthenticated: Guard<AppContext> = (ctx) => ctx.getUser() !== null;
@page({
tag: 'dashboard-page',
routes: ['/dashboard'],
guards: isAuthenticated
})
class DashboardPage extends HTMLElement { }Layouts
Layouts wrap pages with shared UI and dynamically build navigation from page metadata:
// layouts/app-shell.ts
@layout('app-shell')
class AppShell extends HTMLElement implements Layout {
private placards: Placard[] = [];
private currentRoute = '';
@render()
renderContent() {
return html`
<header>
<nav>
${this.placards
.filter(p => p.show !== false)
.map(p => html`
<a href="#/${p.name}"
class="${this.currentRoute === p.name ? 'active' : ''}">
${p.icon} ${p.title}
</a>
`)}
</nav>
</header>
<main><slot name="page"></slot></main>
`;
}
// Called when route changes
update(appContext, placards, currentRoute, routeParams) {
this.placards = placards;
this.currentRoute = currentRoute;
// Property changes trigger re-render
}
}
// main.ts - configure router with layout
const { page, initialize } = Router({
target: '#app',
layout: 'app-shell'
});Pages render inside <slot name="page"></slot>. Layout persists, only page content swaps.
Placards
Page metadata that layouts use to build navigation, breadcrumbs, and help systems:
// pages/dashboard-page.ts
const placard: Placard<AppContext> = {
name: 'dashboard',
title: 'Dashboard',
icon: '📊',
order: 1,
searchTerms: ['home', 'overview', 'stats'],
hotkeys: ['ctrl+d'],
visibleOn: [isAuthenticated]
};
@page({
tag: 'dashboard-page',
routes: ['/dashboard'],
placard: placard
})
class DashboardPage extends HTMLElement { }Features:
- Navigation -
title,icon,order,show - Hierarchy -
parent,group,breadcrumbs - Discovery -
searchTerms,hotkeys,tooltip - Visibility -
visibleOnguards control who sees what
Layouts receive placard data in update() and auto-build navigation. See docs.
Using Snice Components in Other Environments
Standalone Builds
Use any Snice component without installing the library:
snice build-component buttonCreates CDN bundles that work anywhere:
<script src="snice-button.min.js"></script>
<snice-button variant="primary">Click me</snice-button>React Integration
All components have React adapters (React 17+):
import { Button, Input } from 'snice/react';
function MyComponent() {
const [value, setValue] = useState('');
return (
<div>
<Input
value={value}
onChange={(e) => setValue(e.detail.value)}
/>
<Button variant="primary" onClick={() => alert('Clicked!')}>
Submit
</Button>
</div>
);
}See DEVELOPMENT.md for build system details
Documentation
User Documentation
- Elements API - Complete guide to creating elements with properties, queries, and styling
- Controllers API - Data fetching, business logic, and controller patterns
- Routing API - Single-page application routing with transitions
- Placards API - Rich page metadata for dynamic navigation and discovery
- Events API - Event handling, dispatching, and custom events
- Request/Response API - Bidirectional communication between elements and controllers
- Observe API - Lifecycle-managed observers for external changes
Developer Documentation
- DEVELOPMENT.md - Build system, testing, and contributing to Snice
License
MIT
