mantle-lit 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Craig
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,600 @@
1
+ # Mantle Lit
2
+
3
+ A lightweight library for building Lit web components with a simpler class-based API and MobX reactivity built in.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install mantle-lit
9
+ ```
10
+
11
+ Requires Lit 3+ and MobX 6+.
12
+
13
+ ## Basic Example
14
+
15
+ ```ts
16
+ import { View, createView } from 'mantle-lit';
17
+ import { html } from 'lit';
18
+
19
+ interface CounterProps {
20
+ initial: number;
21
+ }
22
+
23
+ class CounterView extends View<CounterProps> {
24
+ count = 0;
25
+
26
+ onCreate() {
27
+ this.count = this.props.initial;
28
+ }
29
+
30
+ increment() {
31
+ this.count++;
32
+ }
33
+
34
+ render() {
35
+ return html`
36
+ <button @click=${this.increment}>
37
+ Count: ${this.count}
38
+ </button>
39
+ `;
40
+ }
41
+ }
42
+
43
+ export const Counter = createView(CounterView, { tag: 'x-counter' });
44
+ ```
45
+
46
+ **Usage in HTML (property binding with `.`):**
47
+ ```html
48
+ <x-counter .initial=${5}></x-counter>
49
+ ```
50
+
51
+ **Everything is reactive by default.** All properties become observable, getters become computed, and methods become auto-bound actions. No annotations needed.
52
+
53
+ > Want explicit control? See [Decorators](#decorators) below to opt into manual annotations.
54
+
55
+ ## Property Binding
56
+
57
+ This library is designed for **property binding** (`.prop=${value}`) rather than attribute binding (`attr="value"`). This allows passing complex objects, arrays, and functions as props.
58
+
59
+ ```ts
60
+ // Parent component
61
+ render() {
62
+ return html`
63
+ <x-todo-list
64
+ .items=${this.todos}
65
+ .onDelete=${this.handleDelete}
66
+ ></x-todo-list>
67
+ `;
68
+ }
69
+ ```
70
+
71
+ ## What You Get
72
+
73
+ **Direct mutation:**
74
+ ```ts
75
+ this.items.push(item); // not [...items, item]
76
+ ```
77
+
78
+ **Computed values via getters:**
79
+ ```ts
80
+ get completed() { // automatically memoized
81
+ return this.items.filter(i => i.done);
82
+ }
83
+ ```
84
+
85
+ **Stable methods (auto-bound):**
86
+ ```ts
87
+ toggle(id: number) { // automatically bound to this
88
+ const item = this.items.find(i => i.id === id);
89
+ if (item) item.done = !item.done;
90
+ }
91
+
92
+ // use directly, no wrapper needed
93
+ render() {
94
+ return html`<button @click=${this.toggle}>Toggle</button>`;
95
+ }
96
+ ```
97
+
98
+ **React to changes explicitly:**
99
+ ```ts
100
+ onCreate() {
101
+ this.watch(
102
+ () => this.props.filter,
103
+ (filter) => this.applyFilter(filter)
104
+ );
105
+ }
106
+ ```
107
+
108
+ ## Lifecycle
109
+
110
+ | Method | When |
111
+ |--------|------|
112
+ | `onCreate()` | Instance created, props available |
113
+ | `onMount()` | Component connected to DOM. Return a cleanup function (optional). |
114
+ | `onUnmount()` | Component disconnected from DOM. Called after cleanups (optional). |
115
+ | `render()` | On mount and updates. Return Lit `TemplateResult`. |
116
+
117
+ ### Watching State
118
+
119
+ Use `this.watch` to react to state changes. Watchers are automatically disposed on unmount.
120
+
121
+ ```ts
122
+ this.watch(
123
+ () => expr, // reactive expression (getter)
124
+ (value, prev) => {}, // callback when expression result changes
125
+ options? // optional: { delay, fireImmediately }
126
+ )
127
+ ```
128
+
129
+ **Options:**
130
+
131
+ | Option | Type | Default | Description |
132
+ |--------|------|---------|-------------|
133
+ | `delay` | `number` | — | Debounce the callback by N milliseconds |
134
+ | `fireImmediately` | `boolean` | `false` | Run callback immediately with current value |
135
+
136
+ **Basic example:**
137
+
138
+ ```ts
139
+ class SearchView extends View<Props> {
140
+ query = '';
141
+ results: string[] = [];
142
+
143
+ onCreate() {
144
+ this.watch(
145
+ () => this.query,
146
+ async (query) => {
147
+ if (query.length > 2) {
148
+ this.results = await searchApi(query);
149
+ }
150
+ },
151
+ { delay: 300 }
152
+ );
153
+ }
154
+ }
155
+ ```
156
+
157
+ **Multiple watchers:**
158
+
159
+ ```ts
160
+ onCreate() {
161
+ this.watch(() => this.props.filter, (filter) => this.applyFilter(filter));
162
+ this.watch(() => this.props.sort, (sort) => this.applySort(sort));
163
+ this.watch(() => this.props.page, (page) => this.fetchPage(page));
164
+ }
165
+ ```
166
+
167
+ **Early disposal:**
168
+
169
+ ```ts
170
+ onCreate() {
171
+ const stop = this.watch(() => this.props.token, (token) => {
172
+ this.authenticate(token);
173
+ stop(); // only needed once
174
+ });
175
+ }
176
+ ```
177
+
178
+ `this.watch` wraps MobX's `reaction` with automatic lifecycle disposal. For advanced MobX patterns (`autorun`, `when`, custom schedulers), use `reaction` directly and return a dispose function from `onMount`.
179
+
180
+ ### Props Reactivity
181
+
182
+ `this.props` is reactive: your component re-renders when accessed props change.
183
+
184
+ **Option 1: `this.watch`** — the recommended way to react to state changes:
185
+
186
+ ```ts
187
+ onCreate() {
188
+ this.watch(
189
+ () => this.props.filter,
190
+ (filter) => this.applyFilter(filter)
191
+ );
192
+ }
193
+ ```
194
+
195
+ Watchers are automatically disposed on unmount. No cleanup needed.
196
+
197
+ **Option 2: `reaction`** — for advanced MobX patterns (autorun, when, custom schedulers):
198
+
199
+ ```ts
200
+ onMount() {
201
+ return reaction(
202
+ () => this.props.filter,
203
+ (filter) => this.applyFilter(filter)
204
+ );
205
+ }
206
+ ```
207
+
208
+ Or access props directly in `render()` and MobX handles re-renders when they change.
209
+
210
+ ## Patterns
211
+
212
+ ### Combined (default)
213
+
214
+ State, logic, and template in one class:
215
+
216
+ ```ts
217
+ import { View, createView } from 'mantle-lit';
218
+ import { html } from 'lit';
219
+
220
+ interface TodoItem {
221
+ id: number;
222
+ text: string;
223
+ done: boolean;
224
+ }
225
+
226
+ class TodoView extends View {
227
+ todos: TodoItem[] = [];
228
+ input = '';
229
+
230
+ add() {
231
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
232
+ this.input = '';
233
+ }
234
+
235
+ setInput(e: Event) {
236
+ this.input = (e.target as HTMLInputElement).value;
237
+ }
238
+
239
+ render() {
240
+ return html`
241
+ <div>
242
+ <input .value=${this.input} @input=${this.setInput} />
243
+ <button @click=${this.add}>Add</button>
244
+ <ul>${this.todos.map(t => html`<li>${t.text}</li>`)}</ul>
245
+ </div>
246
+ `;
247
+ }
248
+ }
249
+
250
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
251
+ ```
252
+
253
+ ### Separated
254
+
255
+ ViewModel and template separate:
256
+
257
+ ```ts
258
+ import { ViewModel, createView } from 'mantle-lit';
259
+ import { html } from 'lit';
260
+
261
+ class TodoViewModel extends ViewModel {
262
+ todos: TodoItem[] = [];
263
+ input = '';
264
+
265
+ add() {
266
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
267
+ this.input = '';
268
+ }
269
+
270
+ setInput(e: Event) {
271
+ this.input = (e.target as HTMLInputElement).value;
272
+ }
273
+ }
274
+
275
+ // Template as a separate function
276
+ const template = (vm: TodoViewModel) => html`
277
+ <div>
278
+ <input .value=${vm.input} @input=${vm.setInput} />
279
+ <button @click=${vm.add}>Add</button>
280
+ <ul>${vm.todos.map(t => html`<li>${t.text}</li>`)}</ul>
281
+ </div>
282
+ `;
283
+
284
+ // Note: For separated templates, extend the View class with a render method
285
+ // that calls the template function
286
+ class TodoView extends TodoViewModel {
287
+ render() {
288
+ return template(this);
289
+ }
290
+ }
291
+
292
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
293
+ ```
294
+
295
+ ## Decorators
296
+
297
+ For teams that prefer explicit annotations over auto-observable, Mantle provides its own decorators. These are lightweight metadata collectors. No `accessor` keyword required.
298
+
299
+ ```ts
300
+ import { View, createView, observable, action, computed } from 'mantle-lit';
301
+ import { html } from 'lit';
302
+
303
+ class TodoView extends View {
304
+ @observable todos: TodoItem[] = [];
305
+ @observable input = '';
306
+
307
+ @computed get remaining() {
308
+ return this.todos.filter(t => !t.done).length;
309
+ }
310
+
311
+ @action add() {
312
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
313
+ this.input = '';
314
+ }
315
+
316
+ render() {
317
+ return html`<!-- ... -->`;
318
+ }
319
+ }
320
+
321
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
322
+ ```
323
+
324
+ **Key differences from auto-observable mode:**
325
+ - Only decorated fields are reactive (undecorated fields are inert)
326
+ - Methods are still auto-bound for stable `this` references
327
+
328
+ ### Available Decorators
329
+
330
+ | Decorator | Purpose |
331
+ |-----------|---------|
332
+ | `@observable` | Deep observable field |
333
+ | `@observable.ref` | Reference-only observation |
334
+ | `@observable.shallow` | Shallow observation (add/remove only) |
335
+ | `@observable.struct` | Structural equality comparison |
336
+ | `@action` | Action method (auto-bound) |
337
+ | `@computed` | Computed getter (optional; getters are computed by default) |
338
+
339
+ ### MobX Decorators (Legacy)
340
+
341
+ If you prefer using MobX's own decorators (requires `accessor` keyword for TC39):
342
+
343
+ ```ts
344
+ import { observable, action } from 'mobx';
345
+ import { configure } from 'mantle-lit';
346
+
347
+ // Disable auto-observable globally
348
+ configure({ autoObservable: false });
349
+
350
+ class TodoView extends View {
351
+ @observable accessor todos: TodoItem[] = []; // note: accessor required
352
+ @action add() { /* ... */ }
353
+ }
354
+
355
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
356
+ ```
357
+
358
+ Note: `this.props` is always reactive regardless of decorator mode.
359
+
360
+ ## Error Handling
361
+
362
+ Render errors propagate to the browser as usual. Lifecycle errors (`onMount`, `onUnmount`, `watch`) in both Views and Behaviors are caught and routed through a configurable handler.
363
+
364
+ By default, errors are logged to `console.error`. Configure a global handler to integrate with your error reporting:
365
+
366
+ ```ts
367
+ import { configure } from 'mantle-lit';
368
+
369
+ configure({
370
+ onError: (error, context) => {
371
+ // context.phase: 'onCreate' | 'onMount' | 'onUnmount' | 'watch'
372
+ // context.name: class name of the View or Behavior
373
+ // context.isBehavior: true if the error came from a Behavior
374
+ Sentry.captureException(error, {
375
+ tags: { phase: context.phase, component: context.name },
376
+ });
377
+ },
378
+ });
379
+ ```
380
+
381
+ Behavior errors are isolated. A failing Behavior won't prevent sibling Behaviors or the parent View from mounting.
382
+
383
+ ## Behaviors (Experimental)
384
+
385
+ > ⚠️ **Experimental:** The Behaviors API is still evolving and may change in future releases.
386
+
387
+ Behaviors are reusable pieces of state and logic that can be shared across views. Define them as classes, wrap with `createBehavior()`, and use the resulting factory function in your Views.
388
+
389
+ ### Defining a Behavior
390
+
391
+ ```ts
392
+ import { Behavior, createBehavior } from 'mantle-lit';
393
+
394
+ class WindowSizeBehavior extends Behavior {
395
+ width = window.innerWidth;
396
+ height = window.innerHeight;
397
+ breakpoint!: number;
398
+
399
+ onCreate(breakpoint = 768) {
400
+ this.breakpoint = breakpoint;
401
+ }
402
+
403
+ get isMobile() {
404
+ return this.width < this.breakpoint;
405
+ }
406
+
407
+ handleResize() {
408
+ this.width = window.innerWidth;
409
+ this.height = window.innerHeight;
410
+ }
411
+
412
+ onMount() {
413
+ window.addEventListener('resize', this.handleResize);
414
+ return () => window.removeEventListener('resize', this.handleResize);
415
+ }
416
+ }
417
+
418
+ export const withWindowSize = createBehavior(WindowSizeBehavior);
419
+ ```
420
+
421
+ The naming convention:
422
+ - **Class**: PascalCase (`WindowSizeBehavior`)
423
+ - **Factory**: camelCase with `with` prefix (`withWindowSize`)
424
+
425
+ ### Using Behaviors
426
+
427
+ Call the factory function (no `new` keyword) in your View. The `with` prefix signals that the View manages this behavior's lifecycle:
428
+
429
+ ```ts
430
+ import { View, createView } from 'mantle-lit';
431
+ import { html } from 'lit';
432
+ import { withWindowSize } from './withWindowSize';
433
+
434
+ class ResponsiveView extends View {
435
+ windowSize = withWindowSize(768);
436
+
437
+ render() {
438
+ return html`
439
+ <div>
440
+ ${this.windowSize.isMobile
441
+ ? html`<mobile-layout></mobile-layout>`
442
+ : html`<desktop-layout></desktop-layout>`}
443
+ <p>Window: ${this.windowSize.width}x${this.windowSize.height}</p>
444
+ </div>
445
+ `;
446
+ }
447
+ }
448
+
449
+ export const Responsive = createView(ResponsiveView, { tag: 'x-responsive' });
450
+ ```
451
+
452
+ ### Watching in Behaviors
453
+
454
+ Behaviors can use `this.watch` just like Views:
455
+
456
+ ```ts
457
+ class FetchBehavior extends Behavior {
458
+ url!: string;
459
+ data: any[] = [];
460
+ loading = false;
461
+
462
+ onCreate(url: string) {
463
+ this.url = url;
464
+ this.watch(() => this.url, () => this.fetchData(), { fireImmediately: true });
465
+ }
466
+
467
+ async fetchData() {
468
+ this.loading = true;
469
+ this.data = await fetch(this.url).then(r => r.json());
470
+ this.loading = false;
471
+ }
472
+ }
473
+
474
+ export const withFetch = createBehavior(FetchBehavior);
475
+ ```
476
+
477
+ ### Multiple Behaviors
478
+
479
+ Behaviors compose naturally:
480
+
481
+ ```ts
482
+ import { View, createView } from 'mantle-lit';
483
+ import { html } from 'lit';
484
+ import { withFetch } from './FetchBehavior';
485
+ import { withWindowSize } from './WindowSizeBehavior';
486
+
487
+ class DashboardView extends View {
488
+ users = withFetch('/api/users');
489
+ posts = withFetch('/api/posts');
490
+ windowSize = withWindowSize(768);
491
+
492
+ render() {
493
+ return html`
494
+ <div>
495
+ ${this.users.loading ? 'Loading...' : `${this.users.data.length} users`}
496
+ ${this.windowSize.isMobile ? html`<mobile-nav></mobile-nav>` : ''}
497
+ </div>
498
+ `;
499
+ }
500
+ }
501
+
502
+ export const Dashboard = createView(DashboardView, { tag: 'x-dashboard' });
503
+ ```
504
+
505
+ ### Behavior Lifecycle
506
+
507
+ Behaviors support the same lifecycle methods as Views:
508
+
509
+ | Method | When |
510
+ |--------|------|
511
+ | `onCreate(...args)` | Called during construction with the factory arguments |
512
+ | `onMount()` | Called when parent View connects to DOM. Return cleanup (optional). |
513
+ | `onUnmount()` | Called when parent View disconnects from DOM. |
514
+
515
+
516
+ ## API
517
+
518
+ ### `configure(config)`
519
+
520
+ Set global defaults for all views. Settings can still be overridden per-view in `createView` options.
521
+
522
+ ```ts
523
+ import { configure } from 'mantle-lit';
524
+
525
+ // Disable auto-observable globally (for decorator users)
526
+ configure({ autoObservable: false });
527
+ ```
528
+
529
+ | Option | Default | Description |
530
+ |--------|---------|-------------|
531
+ | `autoObservable` | `true` | Whether to automatically make View instances observable |
532
+ | `onError` | `console.error` | Global error handler for lifecycle errors (see [Error Handling](#error-handling)) |
533
+
534
+ ### `View<P>` / `ViewModel<P>`
535
+
536
+ Base class for view components. `ViewModel` is an alias for `View`. Use it when separating the ViewModel from the template for semantic clarity.
537
+
538
+ | Property/Method | Description |
539
+ |-----------------|-------------|
540
+ | `props` | Current props (reactive) |
541
+ | `onCreate()` | Called when instance created |
542
+ | `onMount()` | Called when connected to DOM, return cleanup (optional) |
543
+ | `onUnmount()` | Called when disconnected from DOM (optional) |
544
+ | `render()` | Return Lit `TemplateResult` |
545
+ | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
546
+
547
+ ### `Behavior`
548
+
549
+ Base class for behaviors. Extend it and wrap with `createBehavior()`.
550
+
551
+ | Method | Description |
552
+ |--------|-------------|
553
+ | `onCreate(...args)` | Called during construction with constructor args |
554
+ | `onMount()` | Called when parent View mounts, return cleanup (optional) |
555
+ | `onUnmount()` | Called when parent View unmounts |
556
+ | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
557
+
558
+ ### `createBehavior(Class)`
559
+
560
+ Creates a factory function from a behavior class. Returns a callable (no `new` needed).
561
+
562
+ ```ts
563
+ class MyBehavior extends Behavior {
564
+ onCreate(value: string) { /* ... */ }
565
+ }
566
+
567
+ export const withMyBehavior = createBehavior(MyBehavior);
568
+
569
+ // Usage: withMyBehavior('hello')
570
+ ```
571
+
572
+ ### `createView(ViewClass, options)`
573
+
574
+ Function that creates and registers a Lit custom element from a View class.
575
+
576
+ ```ts
577
+ // Basic
578
+ createView(MyView, { tag: 'x-my-view' })
579
+
580
+ // With options
581
+ createView(MyView, { tag: 'x-my-view', autoObservable: false })
582
+ ```
583
+
584
+ | Option | Default | Description |
585
+ |--------|---------|-------------|
586
+ | `tag` | (required) | Custom element tag name (must contain a hyphen) |
587
+ | `autoObservable` | `true` | Make all fields observable. Set to `false` when using decorators. |
588
+ | `shadow` | `true` | Use Shadow DOM. Set to `false` to render in light DOM (allows external CSS). |
589
+
590
+ ## Who This Is For
591
+
592
+ - Teams using MobX for state management
593
+ - Developers who prefer class-based components
594
+ - Projects building standards-compliant web components
595
+ - Anyone integrating vanilla JS libraries
596
+ - Teams wanting to share components across frameworks
597
+
598
+ ## License
599
+
600
+ MIT