snice 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,576 @@
1
+ # Snice
2
+
3
+ A lightweight TypeScript framework for building web components with decorators and routing.
4
+
5
+ ## Core Philosophy: Imperative, Not Reactive
6
+
7
+ Snice takes an **imperative approach** to web components. Unlike reactive frameworks that automatically re-render when data changes, Snice components:
8
+
9
+ - **Render once** when connected to the DOM
10
+ - **Never re-render** automatically
11
+ - Require **explicit method calls** to update visual state
12
+ - Give you **full control** over when and how updates happen
13
+
14
+ This approach eliminates the complexity of reactive systems, virtual DOM diffing, and unexpected re-renders. You write straightforward code that does exactly what you tell it to do.
15
+
16
+ ## Core Concepts
17
+
18
+ Snice provides a clear separation of concerns through decorators:
19
+
20
+ ### Component Decorators
21
+ - **`@element`** - Creates custom HTML elements with encapsulated visual behavior and styling
22
+ - **`@controller`** - Handles data fetching, server communication, and business logic separate from visual components
23
+ - **`@page`** - Sets up routing and navigation between different views
24
+
25
+ ### Property & Query Decorators
26
+ - **`@property`** - Declares reactive properties that can reflect to attributes
27
+ - **`@query`** - Queries a single element from shadow DOM
28
+ - **`@queryAll`** - Queries multiple elements from shadow DOM
29
+
30
+ ### Event Decorators
31
+ - **`@on`** - Listens for events on elements
32
+ - **`@dispatch`** - Dispatches custom events after method execution
33
+ - **`@channel`** - Enables bidirectional communication between elements and controllers
34
+
35
+ This separation keeps your components focused: elements handle presentation, controllers manage data, and pages define navigation.
36
+
37
+ ## Basic Component
38
+
39
+ ```typescript
40
+ import { element } from 'snice';
41
+
42
+ @element('my-button')
43
+ class MyButton extends HTMLElement {
44
+ html() {
45
+ return `<button>Click me</button>`;
46
+ }
47
+ }
48
+ ```
49
+
50
+ That's it. Your component renders when added to the DOM:
51
+
52
+ ```html
53
+ <my-button></my-button>
54
+ ```
55
+
56
+ ## The Imperative Way
57
+
58
+ In Snice, updates are explicit. Components expose methods that controllers or other components call to update state:
59
+
60
+ ```typescript
61
+ import { element, property, query } from 'snice';
62
+
63
+ @element('counter-display')
64
+ class CounterDisplay extends HTMLElement {
65
+ @property({ type: Number })
66
+ count = 0;
67
+
68
+ @query('.count')
69
+ countElement?: HTMLElement;
70
+
71
+ @query('.status')
72
+ statusElement?: HTMLElement;
73
+
74
+ html() {
75
+ // Renders ONCE - no automatic re-rendering
76
+ return `
77
+ <div class="counter">
78
+ <span class="count">${this.count}</span>
79
+ <span class="status">Ready</span>
80
+ </div>
81
+ `;
82
+ }
83
+
84
+ // Imperative update methods - YOU control when updates happen
85
+ setCount(newCount: number) {
86
+ this.count = newCount;
87
+ if (this.countElement) {
88
+ this.countElement.textContent = String(newCount);
89
+ }
90
+ }
91
+
92
+ setStatus(status: string) {
93
+ if (this.statusElement) {
94
+ this.statusElement.textContent = status;
95
+ }
96
+ }
97
+
98
+ increment() {
99
+ this.setCount(this.count + 1);
100
+ this.setStatus('Incremented!');
101
+ }
102
+ }
103
+ ```
104
+
105
+ **Key Points:**
106
+ - The `html()` method runs **once** when the element connects
107
+ - Updates happen through **explicit method calls** like `setCount()`
108
+ - You have **full control** over what updates and when
109
+ - No surprises, no magic, no hidden re-renders
110
+
111
+ ## Properties
112
+
113
+ Properties can be reflected to attributes but do NOT trigger re-renders. The HTML is rendered once when the element connects to the DOM. Use properties for initial configuration.
114
+
115
+ ```typescript
116
+ import { element, property } from 'snice';
117
+
118
+ @element('user-card')
119
+ class UserCard extends HTMLElement {
120
+ @property({ reflect: true })
121
+ name = 'Anonymous';
122
+
123
+ @property({ reflect: true })
124
+ role = 'User';
125
+
126
+ @property({ type: Boolean })
127
+ verified = false;
128
+
129
+ html() {
130
+ // This renders ONCE with the initial property values
131
+ return `
132
+ <div class="card">
133
+ <h3>${this.name}</h3>
134
+ <span class="role">${this.role}</span>
135
+ ${this.verified ? '<span class="badge">✓ Verified</span>' : ''}
136
+ </div>
137
+ `;
138
+ }
139
+ }
140
+ ```
141
+
142
+ Use it with attributes:
143
+ ```html
144
+ <user-card name="Jane Doe" role="Admin" verified></user-card>
145
+ ```
146
+
147
+
148
+ ## Queries
149
+
150
+ Query single elements with `@query`:
151
+
152
+ ```typescript
153
+ import { element, query } from 'snice';
154
+
155
+ @element('my-form')
156
+ class MyForm extends HTMLElement {
157
+ @query('input')
158
+ input?: HTMLInputElement;
159
+
160
+ html() {
161
+ return `<input type="text" />`;
162
+ }
163
+
164
+ getValue() {
165
+ return this.input?.value;
166
+ }
167
+ }
168
+ ```
169
+
170
+ Query multiple elements with `@queryAll`:
171
+
172
+ ```typescript
173
+ import { element, queryAll } from 'snice';
174
+
175
+ @element('checkbox-group')
176
+ class CheckboxGroup extends HTMLElement {
177
+ @queryAll('input[type="checkbox"]')
178
+ checkboxes?: NodeListOf<HTMLInputElement>;
179
+
180
+ html() {
181
+ return `
182
+ <input type="checkbox" value="option1" />
183
+ <input type="checkbox" value="option2" />
184
+ <input type="checkbox" value="option3" />
185
+ `;
186
+ }
187
+
188
+ getSelectedValues() {
189
+ if (!this.checkboxes) return [];
190
+ return Array.from(this.checkboxes)
191
+ .filter(cb => cb.checked)
192
+ .map(cb => cb.value);
193
+ }
194
+ }
195
+ ```
196
+
197
+ ## Events
198
+
199
+ Listen for events with `@on`:
200
+
201
+ ```typescript
202
+ import { element, on } from 'snice';
203
+
204
+ @element('my-clicker')
205
+ class MyClicker extends HTMLElement {
206
+ html() {
207
+ return `<button>Click me</button>`;
208
+ }
209
+
210
+ @on('click', 'button')
211
+ handleClick() {
212
+ console.log('Button clicked!');
213
+ }
214
+ }
215
+ ```
216
+
217
+ ## Dispatching Events
218
+
219
+ Automatically dispatch custom events with `@dispatch`:
220
+
221
+ ```typescript
222
+ import { element, dispatch, on } from 'snice';
223
+
224
+ @element('toggle-switch')
225
+ class ToggleSwitch extends HTMLElement {
226
+ private isOn = false;
227
+
228
+ @query('.toggle')
229
+ toggleButton?: HTMLElement;
230
+
231
+ html() {
232
+ return `
233
+ <button class="toggle">
234
+ <span class="slider"></span>
235
+ </button>
236
+ `;
237
+ }
238
+
239
+ css() {
240
+ return `...`;
241
+ }
242
+
243
+ @on('click', '.toggle')
244
+ @dispatch('toggled')
245
+ toggle() {
246
+ this.isOn = !this.isOn;
247
+ this.toggleButton?.classList.toggle('on', this.isOn);
248
+
249
+ // Return value becomes event detail
250
+ return { on: this.isOn };
251
+ }
252
+ }
253
+ ```
254
+
255
+ The `@dispatch` decorator:
256
+ - Dispatches after the method completes
257
+ - Uses the return value as the event detail
258
+ - Works with async methods
259
+ - Bubbles by default
260
+
261
+ ```typescript
262
+ // With options from EventInit
263
+ @dispatch('my-event', { bubbles: false, cancelable: true })
264
+
265
+ // Don't dispatch if method returns undefined
266
+ @dispatch('maybe-data', { dispatchOnUndefined: false })
267
+ ```
268
+
269
+ ## Styling
270
+
271
+ ```typescript
272
+ @element('styled-card')
273
+ class StyledCard extends HTMLElement {
274
+ html() {
275
+ return `<div class="card">Hello</div>`;
276
+ }
277
+
278
+ css() {
279
+ return `
280
+ .card {
281
+ padding: 20px;
282
+ background: #f0f0f0;
283
+ border-radius: 8px;
284
+ }
285
+ `;
286
+ }
287
+ }
288
+ ```
289
+
290
+ CSS is automatically scoped to your component.
291
+
292
+ ## Routing
293
+
294
+ ```typescript
295
+ import { Router } from 'snice';
296
+
297
+ const router = Router({
298
+ target: '#app',
299
+ routing_type: 'hash'
300
+ });
301
+
302
+ const { page, navigate, initialize } = router;
303
+
304
+ @page({ tag: 'home-page', routes: ['/'] })
305
+ class HomePage extends HTMLElement {
306
+ html() {
307
+ return `<h1>Home</h1>`;
308
+ }
309
+ }
310
+
311
+ @page({ tag: 'about-page', routes: ['/about'] })
312
+ class AboutPage extends HTMLElement {
313
+ html() {
314
+ return `<h1>About</h1>`;
315
+ }
316
+ }
317
+
318
+ // Start the router
319
+ initialize();
320
+
321
+ // Navigate programmatically
322
+ navigate('/about');
323
+ ```
324
+
325
+ ## Controllers (Data Fetching)
326
+
327
+ Controllers handle server communication separately from visual components:
328
+
329
+ ```typescript
330
+ import { controller, element } from 'snice';
331
+
332
+ @controller('user-controller')
333
+ class UserController {
334
+ element: HTMLElement | null = null;
335
+
336
+ async attach(element: HTMLElement) {
337
+ const response = await fetch('/api/users');
338
+ const users = await response.json();
339
+ (element as any).setUsers(users);
340
+ }
341
+
342
+ async detach() {
343
+ // Cleanup
344
+ }
345
+ }
346
+
347
+ @element('user-list')
348
+ class UserList extends HTMLElement {
349
+ users: any[] = [];
350
+
351
+ html() {
352
+ return `
353
+ <ul>
354
+ ${this.users.map(u => `<li>${u.name}</li>`).join('')}
355
+ </ul>
356
+ `;
357
+ }
358
+
359
+ setUsers(users: any[]) {
360
+ this.users = users;
361
+ this.innerHTML = this.html();
362
+ }
363
+ }
364
+ ```
365
+
366
+ Use it:
367
+
368
+ ```html
369
+ <user-list controller="user-controller"></user-list>
370
+ ```
371
+
372
+ ## Channels
373
+
374
+ Request/response between elements and controllers:
375
+
376
+ ```typescript
377
+ // --- components/user-card.ts
378
+ @element('user-card')
379
+ class UserCard extends HTMLElement {
380
+ @channel('get-data')
381
+ async *fetchUserData() {
382
+ // Yield sends request, await waits for response
383
+ const user = await (yield { id: 123 });
384
+ return user;
385
+ }
386
+
387
+ async loadUser() {
388
+ const user = await this.fetchUserData();
389
+ console.log(user); // { name: 'Alice' }
390
+ }
391
+ }
392
+
393
+ // --- controllers/user-controller.ts
394
+ @controller('user-controller')
395
+ class UserController {
396
+ @channel('get-data')
397
+ handleGetData(request) {
398
+ console.log(request); // { id: 123 }
399
+ return { name: 'Alice' };
400
+ }
401
+ }
402
+ ```
403
+
404
+ ## Complete Example
405
+
406
+ Here's how the pieces work together - a generic display component filled by a controller:
407
+
408
+ ```typescript
409
+ import { element, controller, query } from 'snice';
410
+
411
+ // Generic card component - purely visual
412
+ @element('info-card')
413
+ class InfoCard extends HTMLElement {
414
+ @query('.title')
415
+ titleElement?: HTMLElement;
416
+
417
+ @query('.content')
418
+ contentElement?: HTMLElement;
419
+
420
+ @query('.footer')
421
+ footerElement?: HTMLElement;
422
+
423
+ html() {
424
+ return `
425
+ <div class="card">
426
+ <h2 class="title">Loading...</h2>
427
+ <div class="content">
428
+ <div class="skeleton"></div>
429
+ </div>
430
+ <div class="footer"></div>
431
+ </div>
432
+ `;
433
+ }
434
+
435
+ css() {
436
+ return `
437
+ .card {
438
+ border: 1px solid #ddd;
439
+ border-radius: 8px;
440
+ padding: 20px;
441
+ max-width: 400px;
442
+ }
443
+ .title {
444
+ margin: 0 0 15px 0;
445
+ color: #333;
446
+ }
447
+ .content {
448
+ min-height: 60px;
449
+ }
450
+ .skeleton {
451
+ background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
452
+ height: 20px;
453
+ border-radius: 4px;
454
+ animation: loading 1.5s infinite;
455
+ }
456
+ .footer {
457
+ margin-top: 15px;
458
+ font-size: 0.9em;
459
+ color: #666;
460
+ }
461
+ @keyframes loading {
462
+ 0% { background-position: -200px 0; }
463
+ 100% { background-position: 200px 0; }
464
+ }
465
+ `;
466
+ }
467
+
468
+ // Methods the controller can call to update the display
469
+ setTitle(title: string) {
470
+ if (this.titleElement) {
471
+ this.titleElement.textContent = title;
472
+ }
473
+ }
474
+
475
+ setContent(html: string) {
476
+ if (this.contentElement) {
477
+ this.contentElement.innerHTML = html;
478
+ }
479
+ }
480
+
481
+ setFooter(text: string) {
482
+ if (this.footerElement) {
483
+ this.footerElement.textContent = text;
484
+ }
485
+ }
486
+ }
487
+
488
+ // Controller that fetches and populates data
489
+ @controller('weather-controller')
490
+ class WeatherController {
491
+ element: HTMLElement | null = null;
492
+
493
+ async attach(element: HTMLElement) {
494
+ this.element = element;
495
+
496
+ // Simulate fetching weather data
497
+ await new Promise(resolve => setTimeout(resolve, 1000));
498
+
499
+ const weatherData = {
500
+ location: 'San Francisco',
501
+ temp: '72°F',
502
+ conditions: 'Partly Cloudy',
503
+ humidity: '65%'
504
+ };
505
+
506
+ // Update the generic card with specific data
507
+ (element as any).setTitle(weatherData.location);
508
+ (element as any).setContent(`
509
+ <p><strong>${weatherData.temp}</strong></p>
510
+ <p>${weatherData.conditions}</p>
511
+ <p>Humidity: ${weatherData.humidity}</p>
512
+ `);
513
+ (element as any).setFooter('Updated just now');
514
+ }
515
+ }
516
+ ```
517
+
518
+ Use the same card with different controllers:
519
+ ```html
520
+ <!-- Weather widget -->
521
+ <info-card controller="weather-controller"></info-card>
522
+
523
+ <!-- Stock widget - same card, different controller -->
524
+ <info-card controller="stock-controller"></info-card>
525
+
526
+ <!-- News widget - same card, different controller -->
527
+ <info-card controller="news-controller"></info-card>
528
+ ```
529
+
530
+ ## Decorator Reference
531
+
532
+ ### Component Decorators
533
+
534
+ | Decorator | Purpose | Example |
535
+ |-----------|---------|---------|
536
+ | `@element(tagName)` | Defines a custom HTML element | `@element('my-button')` |
537
+ | `@controller(name)` | Creates a data controller | `@controller('user-controller')` |
538
+ | `@page(options)` | Defines a routable page component | `@page({ tag: 'home-page', routes: ['/'] })` |
539
+
540
+ ### Property Decorators
541
+
542
+ | Decorator | Purpose | Example |
543
+ |-----------|---------|---------|
544
+ | `@property(options)` | Declares a property that can reflect to attributes | `@property({ type: Boolean, reflect: true })` |
545
+ | `@query(selector)` | Queries a single element from shadow DOM | `@query('.button')` |
546
+ | `@queryAll(selector)` | Queries multiple elements from shadow DOM | `@queryAll('input[type="checkbox"]')` |
547
+
548
+ ### Event Decorators
549
+
550
+ | Decorator | Purpose | Example |
551
+ |-----------|---------|---------|
552
+ | `@on(event, selector?)` | Listens for DOM events | `@on('click', '.button')` |
553
+ | `@dispatch(eventName, options?)` | Dispatches custom events after method execution | `@dispatch('data-updated')` |
554
+ | `@channel(name, options?)` | Enables request/response communication | `@channel('fetch-data')` |
555
+
556
+ ### Property Options
557
+
558
+ ```typescript
559
+ interface PropertyOptions {
560
+ type?: typeof String | typeof Number | typeof Boolean; // Type converter
561
+ reflect?: boolean; // Reflect property to attribute
562
+ attribute?: string; // Custom attribute name
563
+ }
564
+ ```
565
+
566
+ ### Dispatch Options
567
+
568
+ ```typescript
569
+ interface DispatchOptions extends EventInit {
570
+ dispatchOnUndefined?: boolean; // Whether to dispatch when method returns undefined
571
+ }
572
+ ```
573
+
574
+ ## License
575
+
576
+ MIT