mantle-lit 0.1.2 → 0.2.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 CHANGED
@@ -1,740 +1,753 @@
1
- # Mantle Lit
2
-
3
- A lightweight library for building Lit web components with MobX reactivity. Extends LitElement with automatic observable state, computed getters, and bound actions.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- npm install mantle-lit lit mobx
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
- import { property } from 'lit/decorators.js';
19
-
20
- class CounterView extends View {
21
- // Props - use @property for Lit reactivity and IDE autocomplete
22
- @property({ type: Number, attribute: false })
23
- initialCount = 0;
24
-
25
- // Internal state - auto-observable
26
- count = 0;
27
-
28
- onCreate() {
29
- this.count = this.initialCount;
30
- }
31
-
32
- increment() {
33
- this.count++;
34
- }
35
-
36
- render() {
37
- return html`
38
- <button @click=${this.increment}>
39
- Count: ${this.count}
40
- </button>
41
- `;
42
- }
43
- }
44
-
45
- export const Counter = createView(CounterView, { tag: 'x-counter' });
46
-
47
- // Register type for IDE autocomplete in templates
48
- declare global {
49
- interface HTMLElementTagNameMap {
50
- 'x-counter': CounterView;
51
- }
52
- }
53
- ```
54
-
55
- **Usage in HTML (property binding with `.`):**
56
- ```html
57
- <x-counter .initialCount=${5}></x-counter>
58
- ```
59
-
60
- **Everything is reactive by default.** Internal state becomes observable, getters become computed, and methods become auto-bound actions. Props use Lit's standard `@property()` decorator.
61
-
62
- ## Defining Props
63
-
64
- Use Lit's `@property()` decorator for props. Use `attribute: false` since we pass complex data via property binding:
65
-
66
- ```ts
67
- import { View, createView } from 'mantle-lit';
68
- import { html } from 'lit';
69
- import { property } from 'lit/decorators.js';
70
-
71
- interface TodoItem {
72
- id: number;
73
- text: string;
74
- done: boolean;
75
- }
76
-
77
- class TodoView extends View {
78
- @property({ type: String, attribute: false })
79
- title = '';
80
-
81
- @property({ type: Array, attribute: false })
82
- initialTodos: TodoItem[] = [];
83
-
84
- @property({ attribute: false })
85
- onComplete?: (count: number) => void;
86
-
87
- // Internal state (auto-observable, no decorator needed)
88
- todos: TodoItem[] = [];
89
- }
90
-
91
- export const Todo = createView(TodoView, { tag: 'x-todo' });
92
-
93
- declare global {
94
- interface HTMLElementTagNameMap {
95
- 'x-todo': TodoView;
96
- }
97
- }
98
- ```
99
-
100
- **Why `attribute: false`?** We use property binding (`.prop=${value}`) to pass complex objects, arrays, and functions. Attribute reflection isn't needed and can cause issues with non-primitive types.
101
-
102
- **No props?** Just extend `View` directly without any `@property()` decorators.
103
-
104
- ## Scoped Styles
105
-
106
- Use Lit's `static styles` for component-scoped CSS:
107
-
108
- ```ts
109
- import { View, createView } from 'mantle-lit';
110
- import { html, css } from 'lit';
111
-
112
- class MyView extends View {
113
- static styles = css`
114
- :host {
115
- display: block;
116
- padding: 1rem;
117
- }
118
-
119
- button {
120
- background: #6366f1;
121
- color: white;
122
- }
123
- `;
124
-
125
- render() {
126
- return html`<button>Click me</button>`;
127
- }
128
- }
129
- ```
130
-
131
- For larger components, extract styles to a separate file:
132
-
133
- ```ts
134
- // MyView.styles.ts
135
- import { css } from 'lit';
136
- export const styles = css`...`;
137
-
138
- // MyView.ts
139
- import { styles } from './MyView.styles';
140
-
141
- class MyView extends View {
142
- static styles = styles;
143
- // ...
144
- }
145
- ```
146
-
147
- ## What You Get
148
-
149
- **Direct mutation:**
150
- ```ts
151
- this.items.push(item); // not [...items, item]
152
- ```
153
-
154
- **Computed values via getters:**
155
- ```ts
156
- get completed() { // automatically memoized
157
- return this.items.filter(i => i.done);
158
- }
159
- ```
160
-
161
- **Stable methods (auto-bound):**
162
- ```ts
163
- toggle(id: number) { // automatically bound to this
164
- const item = this.items.find(i => i.id === id);
165
- if (item) item.done = !item.done;
166
- }
167
-
168
- // use directly, no wrapper needed
169
- render() {
170
- return html`<button @click=${this.toggle}>Toggle</button>`;
171
- }
172
- ```
173
-
174
- **React to changes explicitly:**
175
- ```ts
176
- onCreate() {
177
- this.watch(
178
- () => this.filter,
179
- (filter) => this.applyFilter(filter)
180
- );
181
- }
182
- ```
183
-
184
- ## Lifecycle
185
-
186
- | Method | When |
187
- |--------|------|
188
- | `onCreate()` | Instance created, props available |
189
- | `onMount()` | Component connected to DOM. Return a cleanup function (optional). |
190
- | `onUnmount()` | Component disconnected from DOM. Called after cleanups (optional). |
191
- | `render()` | On mount and updates. Return Lit `TemplateResult`. |
192
-
193
- ### Watching State
194
-
195
- Use `this.watch` to react to state changes. Watchers are automatically disposed on unmount.
196
-
197
- ```ts
198
- this.watch(
199
- () => expr, // reactive expression (getter)
200
- (value, prev) => {}, // callback when expression result changes
201
- options? // optional: { delay, fireImmediately }
202
- )
203
- ```
204
-
205
- **Options:**
206
-
207
- | Option | Type | Default | Description |
208
- |--------|------|---------|-------------|
209
- | `delay` | `number` | — | Debounce the callback by N milliseconds |
210
- | `fireImmediately` | `boolean` | `false` | Run callback immediately with current value |
211
-
212
- **Basic example:**
213
-
214
- ```ts
215
- class SearchView extends View {
216
- @property({ type: String, attribute: false })
217
- placeholder = '';
218
-
219
- query = '';
220
- results: string[] = [];
221
-
222
- onCreate() {
223
- this.watch(
224
- () => this.query,
225
- async (query) => {
226
- if (query.length > 2) {
227
- this.results = await searchApi(query);
228
- }
229
- },
230
- { delay: 300 }
231
- );
232
- }
233
- }
234
- ```
235
-
236
- **Multiple watchers:**
237
-
238
- ```ts
239
- onCreate() {
240
- this.watch(() => this.filter, (filter) => this.applyFilter(filter));
241
- this.watch(() => this.sort, (sort) => this.applySort(sort));
242
- this.watch(() => this.page, (page) => this.fetchPage(page));
243
- }
244
- ```
245
-
246
- **Early disposal:**
247
-
248
- ```ts
249
- onCreate() {
250
- const stop = this.watch(() => this.token, (token) => {
251
- this.authenticate(token);
252
- stop(); // only needed once
253
- });
254
- }
255
- ```
256
-
257
- `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`.
258
-
259
- ## Mounting Components
260
-
261
- Use the `mount` helper to imperatively create and mount components:
262
-
263
- ```ts
264
- import { mount } from 'mantle-lit';
265
- import './MyComponent';
266
-
267
- // Mount with props
268
- mount('x-my-component', {
269
- title: 'Hello',
270
- items: [1, 2, 3],
271
- onSelect: (item) => console.log(item),
272
- }, document.body);
273
-
274
- // Returns the created element
275
- const el = mount('x-counter', { initialCount: 5 }, container);
276
- ```
277
-
278
- ## IDE Autocomplete
279
-
280
- For IDE autocomplete in Lit templates, add `HTMLElementTagNameMap` declarations:
281
-
282
- ```ts
283
- declare global {
284
- interface HTMLElementTagNameMap {
285
- 'x-my-component': MyComponentView;
286
- }
287
- }
288
- ```
289
-
290
- Install the [lit-plugin](https://marketplace.visualstudio.com/items?itemName=runem.lit-plugin) VS Code extension for template type checking.
291
-
292
- **CLI validation** (works reliably):
293
- ```bash
294
- npx lit-analyzer "src/**/*.ts" --strict
295
- ```
296
-
297
- Add to your `package.json`:
298
- ```json
299
- {
300
- "scripts": {
301
- "lint:lit": "lit-analyzer \"src/**/*.ts\" --strict"
302
- }
303
- }
304
- ```
305
-
306
- ## TypeScript Configuration
307
-
308
- For `@property()` decorators to work correctly:
309
-
310
- ```json
311
- {
312
- "compilerOptions": {
313
- "experimentalDecorators": true,
314
- "useDefineForClassFields": false
315
- }
316
- }
317
- ```
318
-
319
- ## Patterns
320
-
321
- ### Combined (default)
322
-
323
- State, logic, and template in one class:
324
-
325
- ```ts
326
- import { View, createView } from 'mantle-lit';
327
- import { html } from 'lit';
328
- import { property } from 'lit/decorators.js';
329
-
330
- interface TodoItem {
331
- id: number;
332
- text: string;
333
- done: boolean;
334
- }
335
-
336
- class TodoView extends View {
337
- @property({ type: Array, attribute: false })
338
- initialTodos: TodoItem[] = [];
339
-
340
- todos: TodoItem[] = [];
341
- input = '';
342
-
343
- onCreate() {
344
- this.todos = this.initialTodos;
345
- }
346
-
347
- add() {
348
- this.todos.push({ id: Date.now(), text: this.input, done: false });
349
- this.input = '';
350
- }
351
-
352
- setInput(e: Event) {
353
- this.input = (e.target as HTMLInputElement).value;
354
- }
355
-
356
- render() {
357
- return html`
358
- <div>
359
- <input .value=${this.input} @input=${this.setInput} />
360
- <button @click=${this.add}>Add</button>
361
- <ul>${this.todos.map(t => html`<li>${t.text}</li>`)}</ul>
362
- </div>
363
- `;
364
- }
365
- }
366
-
367
- export const Todo = createView(TodoView, { tag: 'x-todo' });
368
-
369
- declare global {
370
- interface HTMLElementTagNameMap {
371
- 'x-todo': TodoView;
372
- }
373
- }
374
- ```
375
-
376
- ### Separated
377
-
378
- ViewModel and template separate:
379
-
380
- ```ts
381
- import { View, createView } from 'mantle-lit';
382
- import { html } from 'lit';
383
-
384
- class TodoViewModel extends View {
385
- todos: TodoItem[] = [];
386
- input = '';
387
-
388
- add() {
389
- this.todos.push({ id: Date.now(), text: this.input, done: false });
390
- this.input = '';
391
- }
392
-
393
- setInput(e: Event) {
394
- this.input = (e.target as HTMLInputElement).value;
395
- }
396
- }
397
-
398
- // Template as a separate function
399
- const template = (vm: TodoViewModel) => html`
400
- <div>
401
- <input .value=${vm.input} @input=${vm.setInput} />
402
- <button @click=${vm.add}>Add</button>
403
- <ul>${vm.todos.map(t => html`<li>${t.text}</li>`)}</ul>
404
- </div>
405
- `;
406
-
407
- class TodoView extends TodoViewModel {
408
- render() {
409
- return template(this);
410
- }
411
- }
412
-
413
- export const Todo = createView(TodoView, { tag: 'x-todo' });
414
- ```
415
-
416
- ## Decorators
417
-
418
- For teams that prefer explicit annotations over auto-observable, Mantle provides its own decorators. These are lightweight metadata collectors. No `accessor` keyword required.
419
-
420
- ```ts
421
- import { View, createView, observable, action, computed } from 'mantle-lit';
422
- import { html } from 'lit';
423
- import { property } from 'lit/decorators.js';
424
-
425
- class TodoView extends View {
426
- @property({ type: String, attribute: false })
427
- title = '';
428
-
429
- @observable todos: TodoItem[] = [];
430
- @observable input = '';
431
-
432
- @computed get remaining() {
433
- return this.todos.filter(t => !t.done).length;
434
- }
435
-
436
- @action add() {
437
- this.todos.push({ id: Date.now(), text: this.input, done: false });
438
- this.input = '';
439
- }
440
-
441
- render() {
442
- return html`<!-- ... -->`;
443
- }
444
- }
445
-
446
- export const Todo = createView(TodoView, { tag: 'x-todo' });
447
- ```
448
-
449
- **Key differences from auto-observable mode:**
450
- - Only decorated fields are reactive (undecorated fields are inert)
451
- - Methods are still auto-bound for stable `this` references
452
-
453
- ### Available Decorators
454
-
455
- | Decorator | Purpose |
456
- |-----------|---------|
457
- | `@observable` | Deep observable field |
458
- | `@observable.ref` | Reference-only observation |
459
- | `@observable.shallow` | Shallow observation (add/remove only) |
460
- | `@observable.struct` | Structural equality comparison |
461
- | `@action` | Action method (auto-bound) |
462
- | `@computed` | Computed getter (optional; getters are computed by default) |
463
-
464
- ### MobX Decorators (Legacy)
465
-
466
- If you prefer using MobX's own decorators (requires `accessor` keyword for TC39):
467
-
468
- ```ts
469
- import { observable, action } from 'mobx';
470
- import { configure } from 'mantle-lit';
471
-
472
- // Disable auto-observable globally
473
- configure({ autoObservable: false });
474
-
475
- class TodoView extends View {
476
- @observable accessor todos: TodoItem[] = []; // note: accessor required
477
- @action add() { /* ... */ }
478
- }
479
-
480
- export const Todo = createView(TodoView, { tag: 'x-todo' });
481
- ```
482
-
483
- ## Error Handling
484
-
485
- 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.
486
-
487
- By default, errors are logged to `console.error`. Configure a global handler to integrate with your error reporting:
488
-
489
- ```ts
490
- import { configure } from 'mantle-lit';
491
-
492
- configure({
493
- onError: (error, context) => {
494
- // context.phase: 'onCreate' | 'onMount' | 'onUnmount' | 'watch'
495
- // context.name: class name of the View or Behavior
496
- // context.isBehavior: true if the error came from a Behavior
497
- Sentry.captureException(error, {
498
- tags: { phase: context.phase, component: context.name },
499
- });
500
- },
501
- });
502
- ```
503
-
504
- Behavior errors are isolated. A failing Behavior won't prevent sibling Behaviors or the parent View from mounting.
505
-
506
- ## Behaviors (Experimental)
507
-
508
- > ⚠️ **Experimental:** The Behaviors API is still evolving and may change in future releases.
509
-
510
- 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.
511
-
512
- ### Defining a Behavior
513
-
514
- ```ts
515
- import { Behavior, createBehavior } from 'mantle-lit';
516
-
517
- class WindowSizeBehavior extends Behavior {
518
- width = window.innerWidth;
519
- height = window.innerHeight;
520
- breakpoint!: number;
521
-
522
- onCreate(breakpoint = 768) {
523
- this.breakpoint = breakpoint;
524
- }
525
-
526
- get isMobile() {
527
- return this.width < this.breakpoint;
528
- }
529
-
530
- handleResize() {
531
- this.width = window.innerWidth;
532
- this.height = window.innerHeight;
533
- }
534
-
535
- onMount() {
536
- window.addEventListener('resize', this.handleResize);
537
- return () => window.removeEventListener('resize', this.handleResize);
538
- }
539
- }
540
-
541
- export const withWindowSize = createBehavior(WindowSizeBehavior);
542
- ```
543
-
544
- The naming convention:
545
- - **Class**: PascalCase (`WindowSizeBehavior`)
546
- - **Factory**: camelCase with `with` prefix (`withWindowSize`)
547
-
548
- ### Using Behaviors
549
-
550
- Call the factory function (no `new` keyword) in your View. The `with` prefix signals that the View manages this behavior's lifecycle:
551
-
552
- ```ts
553
- import { View, createView } from 'mantle-lit';
554
- import { html } from 'lit';
555
- import { withWindowSize } from './withWindowSize';
556
-
557
- class ResponsiveView extends View {
558
- windowSize = withWindowSize(768);
559
-
560
- render() {
561
- return html`
562
- <div>
563
- ${this.windowSize.isMobile
564
- ? html`<mobile-layout></mobile-layout>`
565
- : html`<desktop-layout></desktop-layout>`}
566
- <p>Window: ${this.windowSize.width}x${this.windowSize.height}</p>
567
- </div>
568
- `;
569
- }
570
- }
571
-
572
- export const Responsive = createView(ResponsiveView, { tag: 'x-responsive' });
573
- ```
574
-
575
- ### Watching in Behaviors
576
-
577
- Behaviors can use `this.watch` just like Views:
578
-
579
- ```ts
580
- class FetchBehavior extends Behavior {
581
- url!: string;
582
- data: any[] = [];
583
- loading = false;
584
-
585
- onCreate(url: string) {
586
- this.url = url;
587
- this.watch(() => this.url, () => this.fetchData(), { fireImmediately: true });
588
- }
589
-
590
- async fetchData() {
591
- this.loading = true;
592
- this.data = await fetch(this.url).then(r => r.json());
593
- this.loading = false;
594
- }
595
- }
596
-
597
- export const withFetch = createBehavior(FetchBehavior);
598
- ```
599
-
600
- ### Multiple Behaviors
601
-
602
- Behaviors compose naturally:
603
-
604
- ```ts
605
- import { View, createView } from 'mantle-lit';
606
- import { html } from 'lit';
607
- import { withFetch } from './FetchBehavior';
608
- import { withWindowSize } from './WindowSizeBehavior';
609
-
610
- class DashboardView extends View {
611
- users = withFetch('/api/users');
612
- posts = withFetch('/api/posts');
613
- windowSize = withWindowSize(768);
614
-
615
- render() {
616
- return html`
617
- <div>
618
- ${this.users.loading ? 'Loading...' : `${this.users.data.length} users`}
619
- ${this.windowSize.isMobile ? html`<mobile-nav></mobile-nav>` : ''}
620
- </div>
621
- `;
622
- }
623
- }
624
-
625
- export const Dashboard = createView(DashboardView, { tag: 'x-dashboard' });
626
- ```
627
-
628
- ### Behavior Lifecycle
629
-
630
- Behaviors support the same lifecycle methods as Views:
631
-
632
- | Method | When |
633
- |--------|------|
634
- | `onCreate(...args)` | Called during construction with the factory arguments |
635
- | `onMount()` | Called when parent View connects to DOM. Return cleanup (optional). |
636
- | `onUnmount()` | Called when parent View disconnects from DOM. |
637
-
638
-
639
- ## API
640
-
641
- ### `configure(config)`
642
-
643
- Set global defaults for all views. Settings can still be overridden per-view in `createView` options.
644
-
645
- ```ts
646
- import { configure } from 'mantle-lit';
647
-
648
- // Disable auto-observable globally (for decorator users)
649
- configure({ autoObservable: false });
650
- ```
651
-
652
- | Option | Default | Description |
653
- |--------|---------|-------------|
654
- | `autoObservable` | `true` | Whether to automatically make View instances observable |
655
- | `onError` | `console.error` | Global error handler for lifecycle errors (see [Error Handling](#error-handling)) |
656
-
657
- ### `View`
658
-
659
- Base class for view components. Extends `LitElement` with MobX integration.
660
-
661
- | Property/Method | Description |
662
- |-----------------|-------------|
663
- | `onCreate()` | Called when instance created |
664
- | `onMount()` | Called when connected to DOM, return cleanup (optional) |
665
- | `onUnmount()` | Called when disconnected from DOM (optional) |
666
- | `render()` | Return Lit `TemplateResult` |
667
- | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
668
-
669
- ### `mount(tag, props, container)`
670
-
671
- Imperatively create and mount a custom element:
672
-
673
- ```ts
674
- import { mount } from 'mantle-lit';
675
-
676
- const element = mount('x-my-component', { title: 'Hello' }, document.body);
677
- ```
678
-
679
- | Argument | Type | Description |
680
- |----------|------|-------------|
681
- | `tag` | `string` | Custom element tag name |
682
- | `props` | `object` | Properties to set on the element |
683
- | `container` | `Element \| string` | Container element or selector |
684
-
685
- Returns the created element.
686
-
687
- ### `Behavior`
688
-
689
- Base class for behaviors. Extend it and wrap with `createBehavior()`.
690
-
691
- | Method | Description |
692
- |--------|-------------|
693
- | `onCreate(...args)` | Called during construction with constructor args |
694
- | `onMount()` | Called when parent View mounts, return cleanup (optional) |
695
- | `onUnmount()` | Called when parent View unmounts |
696
- | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
697
-
698
- ### `createBehavior(Class)`
699
-
700
- Creates a factory function from a behavior class. Returns a callable (no `new` needed).
701
-
702
- ```ts
703
- class MyBehavior extends Behavior {
704
- onCreate(value: string) { /* ... */ }
705
- }
706
-
707
- export const withMyBehavior = createBehavior(MyBehavior);
708
-
709
- // Usage: withMyBehavior('hello')
710
- ```
711
-
712
- ### `createView(ViewClass, options)`
713
-
714
- Function that registers a View class as a custom element.
715
-
716
- ```ts
717
- // Basic
718
- createView(MyView, { tag: 'x-my-view' })
719
-
720
- // With options
721
- createView(MyView, { tag: 'x-my-view', autoObservable: false })
722
- ```
723
-
724
- | Option | Default | Description |
725
- |--------|---------|-------------|
726
- | `tag` | (required) | Custom element tag name (must contain a hyphen) |
727
- | `autoObservable` | `true` | Make all fields observable. Set to `false` when using decorators. |
728
- | `shadow` | `true` | Use Shadow DOM. Set to `false` to render in light DOM (allows external CSS). |
729
-
730
- ## Who This Is For
731
-
732
- - Teams using MobX for state management
733
- - Developers who prefer class-based components
734
- - Projects building standards-compliant web components
735
- - Anyone integrating vanilla JS libraries
736
- - Teams wanting to share components across frameworks
737
-
738
- ## License
739
-
740
- MIT
1
+ # Mantle Lit
2
+
3
+ A lightweight library for building web components with MobX reactivity and lit-html templating. Automatic observable state, computed getters, and bound actions.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install mantle-lit lit mobx
9
+ ```
10
+
11
+ Requires Lit 3+ and MobX 6+.
12
+
13
+ ## Basic Example
14
+
15
+ ```ts
16
+ import { View, createView, property, html } from 'mantle-lit';
17
+
18
+ class CounterView extends View {
19
+ // Props
20
+ @property() initialCount = 0;
21
+
22
+ // Internal state - auto-observable
23
+ count = 0;
24
+
25
+ onCreate() {
26
+ this.count = this.initialCount;
27
+ }
28
+
29
+ increment() {
30
+ this.count++;
31
+ }
32
+
33
+ render() {
34
+ return html`
35
+ <button @click=${this.increment}>
36
+ Count: ${this.count}
37
+ </button>
38
+ `;
39
+ }
40
+ }
41
+
42
+ export const Counter = createView(CounterView, { tag: 'x-counter' });
43
+
44
+ // Register type for IDE autocomplete in templates
45
+ declare global {
46
+ interface HTMLElementTagNameMap {
47
+ 'x-counter': CounterView;
48
+ }
49
+ }
50
+ ```
51
+
52
+ **Usage in HTML (property binding with `.`):**
53
+ ```html
54
+ <x-counter .initialCount=${5}></x-counter>
55
+ ```
56
+
57
+ **Everything is reactive by default.** Internal state becomes observable, getters become computed, and methods become auto-bound actions. Props use the `@property()` decorator for IDE autocomplete.
58
+
59
+ ## Defining Props
60
+
61
+ Use `@property()` for props:
62
+
63
+ ```ts
64
+ import { View, createView, property, html } from 'mantle-lit';
65
+
66
+ interface TodoItem {
67
+ id: number;
68
+ text: string;
69
+ done: boolean;
70
+ }
71
+
72
+ class TodoView extends View {
73
+ @property() title = '';
74
+ @property() initialTodos: TodoItem[] = [];
75
+ @property() onComplete?: (count: number) => void;
76
+
77
+ // Internal state (auto-observable, no decorator needed)
78
+ todos: TodoItem[] = [];
79
+ }
80
+
81
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
82
+
83
+ declare global {
84
+ interface HTMLElementTagNameMap {
85
+ 'x-todo': TodoView;
86
+ }
87
+ }
88
+ ```
89
+
90
+ Use property binding (`.prop=${value}`) to pass props in templates.
91
+
92
+ **No props?** Just extend `View` directly without any `@property()` decorators.
93
+
94
+ ## Scoped Styles
95
+
96
+ Use `static styles` for component-scoped CSS:
97
+
98
+ ```ts
99
+ import { View, createView, html, css } from 'mantle-lit';
100
+
101
+ class MyView extends View {
102
+ static styles = css`
103
+ :host {
104
+ display: block;
105
+ padding: 1rem;
106
+ }
107
+
108
+ button {
109
+ background: #6366f1;
110
+ color: white;
111
+ }
112
+ `;
113
+
114
+ render() {
115
+ return html`<button>Click me</button>`;
116
+ }
117
+ }
118
+ ```
119
+
120
+ For larger components, extract styles to a separate file:
121
+
122
+ ```ts
123
+ // MyView.styles.ts
124
+ import { css } from 'mantle-lit';
125
+ export const styles = css`...`;
126
+
127
+ // MyView.ts
128
+ import { styles } from './MyView.styles';
129
+
130
+ class MyView extends View {
131
+ static styles = styles;
132
+ // ...
133
+ }
134
+ ```
135
+
136
+ ## What You Get
137
+
138
+ **Direct mutation:**
139
+ ```ts
140
+ this.items.push(item); // not [...items, item]
141
+ ```
142
+
143
+ **Computed values via getters:**
144
+ ```ts
145
+ get completed() { // automatically memoized
146
+ return this.items.filter(i => i.done);
147
+ }
148
+ ```
149
+
150
+ **Stable methods (auto-bound):**
151
+ ```ts
152
+ toggle(id: number) { // automatically bound to this
153
+ const item = this.items.find(i => i.id === id);
154
+ if (item) item.done = !item.done;
155
+ }
156
+
157
+ // use directly, no wrapper needed
158
+ render() {
159
+ return html`<button @click=${this.toggle}>Toggle</button>`;
160
+ }
161
+ ```
162
+
163
+ **React to changes explicitly:**
164
+ ```ts
165
+ onCreate() {
166
+ this.watch(
167
+ () => this.filter,
168
+ (filter) => this.applyFilter(filter)
169
+ );
170
+ }
171
+ ```
172
+
173
+ ## Lifecycle
174
+
175
+ | Method | When |
176
+ |--------|------|
177
+ | `onCreate()` | Instance created, props available |
178
+ | `onMount()` | Component connected to DOM. Return a cleanup function (optional). |
179
+ | `onUnmount()` | Component disconnected from DOM. Called after cleanups (optional). |
180
+ | `render()` | On mount and updates. Return Lit `TemplateResult`. |
181
+
182
+ ### Watching State
183
+
184
+ Use `this.watch` to react to state changes. Watchers are automatically disposed on unmount.
185
+
186
+ ```ts
187
+ this.watch(
188
+ () => expr, // reactive expression (getter)
189
+ (value, prev) => {}, // callback when expression result changes
190
+ options? // optional: { delay, fireImmediately }
191
+ )
192
+ ```
193
+
194
+ **Options:**
195
+
196
+ | Option | Type | Default | Description |
197
+ |--------|------|---------|-------------|
198
+ | `delay` | `number` | — | Debounce the callback by N milliseconds |
199
+ | `fireImmediately` | `boolean` | `false` | Run callback immediately with current value |
200
+
201
+ **Basic example:**
202
+
203
+ ```ts
204
+ class SearchView extends View {
205
+ @property() placeholder = '';
206
+
207
+ query = '';
208
+ results: string[] = [];
209
+
210
+ onCreate() {
211
+ this.watch(
212
+ () => this.query,
213
+ async (query) => {
214
+ if (query.length > 2) {
215
+ this.results = await searchApi(query);
216
+ }
217
+ },
218
+ { delay: 300 }
219
+ );
220
+ }
221
+ }
222
+ ```
223
+
224
+ **Multiple watchers:**
225
+
226
+ ```ts
227
+ onCreate() {
228
+ this.watch(() => this.filter, (filter) => this.applyFilter(filter));
229
+ this.watch(() => this.sort, (sort) => this.applySort(sort));
230
+ this.watch(() => this.page, (page) => this.fetchPage(page));
231
+ }
232
+ ```
233
+
234
+ **Early disposal:**
235
+
236
+ ```ts
237
+ onCreate() {
238
+ const stop = this.watch(() => this.token, (token) => {
239
+ this.authenticate(token);
240
+ stop(); // only needed once
241
+ });
242
+ }
243
+ ```
244
+
245
+ `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`.
246
+
247
+ ## Mounting Components
248
+
249
+ Use the `mount` helper to imperatively create and mount components:
250
+
251
+ ```ts
252
+ import { mount } from 'mantle-lit';
253
+ import './MyComponent';
254
+
255
+ // Mount with props
256
+ mount('x-my-component', {
257
+ title: 'Hello',
258
+ items: [1, 2, 3],
259
+ onSelect: (item) => console.log(item),
260
+ }, document.body);
261
+
262
+ // Returns the created element
263
+ const el = mount('x-counter', { initialCount: 5 }, container);
264
+ ```
265
+
266
+ ## IDE Autocomplete
267
+
268
+ For IDE autocomplete in Lit templates, add `HTMLElementTagNameMap` declarations:
269
+
270
+ ```ts
271
+ declare global {
272
+ interface HTMLElementTagNameMap {
273
+ 'x-my-component': MyComponentView;
274
+ }
275
+ }
276
+ ```
277
+
278
+ Install the [lit-plugin](https://marketplace.visualstudio.com/items?itemName=runem.lit-plugin) VS Code extension for template type checking.
279
+
280
+ **CLI validation** (works reliably):
281
+ ```bash
282
+ npx lit-analyzer "src/**/*.ts" --strict
283
+ ```
284
+
285
+ Add to your `package.json`:
286
+ ```json
287
+ {
288
+ "scripts": {
289
+ "lint:lit": "lit-analyzer \"src/**/*.ts\" --strict"
290
+ }
291
+ }
292
+ ```
293
+
294
+ ## TypeScript Configuration
295
+
296
+ Enable experimental decorators:
297
+
298
+ ```json
299
+ {
300
+ "compilerOptions": {
301
+ "experimentalDecorators": true
302
+ }
303
+ }
304
+ ```
305
+
306
+ ## Patterns
307
+
308
+ ### Combined (default)
309
+
310
+ State, logic, and template in one class with a `render()` method:
311
+
312
+ ```ts
313
+ import { View, createView, property, html } from 'mantle-lit';
314
+
315
+ interface TodoItem {
316
+ id: number;
317
+ text: string;
318
+ done: boolean;
319
+ }
320
+
321
+ class TodoView extends View {
322
+ @property() initialTodos: TodoItem[] = [];
323
+
324
+ todos: TodoItem[] = [];
325
+ input = '';
326
+
327
+ onCreate() {
328
+ this.todos = this.initialTodos;
329
+ }
330
+
331
+ add() {
332
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
333
+ this.input = '';
334
+ }
335
+
336
+ setInput(e: Event) {
337
+ this.input = (e.target as HTMLInputElement).value;
338
+ }
339
+
340
+ render() {
341
+ return html`
342
+ <div>
343
+ <input .value=${this.input} @input=${this.setInput} />
344
+ <button @click=${this.add}>Add</button>
345
+ <ul>${this.todos.map(t => html`<li>${t.text}</li>`)}</ul>
346
+ </div>
347
+ `;
348
+ }
349
+ }
350
+
351
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
352
+
353
+ declare global {
354
+ interface HTMLElementTagNameMap {
355
+ 'x-todo': TodoView;
356
+ }
357
+ }
358
+ ```
359
+
360
+ ### Separated
361
+
362
+ ViewModel (state/logic) and template as separate concerns. Pass the template to `createView`:
363
+
364
+ ```ts
365
+ import { ViewModel, createView, property, html, css } from 'mantle-lit';
366
+
367
+ interface TodoItem {
368
+ id: number;
369
+ text: string;
370
+ done: boolean;
371
+ }
372
+
373
+ // ViewModel: pure state and logic (no render method)
374
+ class TodoVM extends ViewModel {
375
+ @property() initialTodos: TodoItem[] = [];
376
+
377
+ todos: TodoItem[] = [];
378
+ input = '';
379
+
380
+ onCreate() {
381
+ this.todos = this.initialTodos;
382
+ }
383
+
384
+ add() {
385
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
386
+ this.input = '';
387
+ }
388
+
389
+ setInput(e: Event) {
390
+ this.input = (e.target as HTMLInputElement).value;
391
+ }
392
+ }
393
+
394
+ // Template: pure presentation
395
+ const template = (vm: TodoVM) => html`
396
+ <div>
397
+ <input .value=${vm.input} @input=${vm.setInput} />
398
+ <button @click=${vm.add}>Add</button>
399
+ <ul>${vm.todos.map(t => html`<li>${t.text}</li>`)}</ul>
400
+ </div>
401
+ `;
402
+
403
+ const styles = css`
404
+ button { background: #6366f1; color: white; }
405
+ `;
406
+
407
+ // createView wires them together
408
+ export const Todo = createView(TodoVM, {
409
+ tag: 'x-todo',
410
+ template,
411
+ styles,
412
+ });
413
+
414
+ declare global {
415
+ interface HTMLElementTagNameMap {
416
+ 'x-todo': TodoVM;
417
+ }
418
+ }
419
+ ```
420
+
421
+ **Benefits of separation:**
422
+ - **Testable**: ViewModel is pure JS, unit test without DOM
423
+ - **Portable**: Same ViewModel could render to React, Vue, etc.
424
+ - **Cleaner**: State logic separate from presentation
425
+
426
+ ## Decorators
427
+
428
+ For teams that prefer explicit annotations over auto-observable, Mantle provides its own decorators. These are lightweight metadata collectors. No `accessor` keyword required.
429
+
430
+ ```ts
431
+ import { View, createView, property, observable, action, computed, html } from 'mantle-lit';
432
+
433
+ class TodoView extends View {
434
+ @property() title = '';
435
+
436
+ @observable todos: TodoItem[] = [];
437
+ @observable input = '';
438
+
439
+ @computed get remaining() {
440
+ return this.todos.filter(t => !t.done).length;
441
+ }
442
+
443
+ @action add() {
444
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
445
+ this.input = '';
446
+ }
447
+
448
+ render() {
449
+ return html`<!-- ... -->`;
450
+ }
451
+ }
452
+
453
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
454
+ ```
455
+
456
+ **Key differences from auto-observable mode:**
457
+ - Only decorated fields are reactive (undecorated fields are inert)
458
+ - Methods are still auto-bound for stable `this` references
459
+
460
+ ### Available Decorators
461
+
462
+ | Decorator | Purpose |
463
+ |-----------|---------|
464
+ | `@observable` | Deep observable field |
465
+ | `@observable.ref` | Reference-only observation |
466
+ | `@observable.shallow` | Shallow observation (add/remove only) |
467
+ | `@observable.struct` | Structural equality comparison |
468
+ | `@action` | Action method (auto-bound) |
469
+ | `@computed` | Computed getter (optional; getters are computed by default) |
470
+
471
+ ### MobX Decorators (Legacy)
472
+
473
+ If you prefer using MobX's own decorators (requires `accessor` keyword for TC39):
474
+
475
+ ```ts
476
+ import { observable, action } from 'mobx';
477
+ import { configure } from 'mantle-lit';
478
+
479
+ // Disable auto-observable globally
480
+ configure({ autoObservable: false });
481
+
482
+ class TodoView extends View {
483
+ @observable accessor todos: TodoItem[] = []; // note: accessor required
484
+ @action add() { /* ... */ }
485
+ }
486
+
487
+ export const Todo = createView(TodoView, { tag: 'x-todo' });
488
+ ```
489
+
490
+ ## Error Handling
491
+
492
+ 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.
493
+
494
+ By default, errors are logged to `console.error`. Configure a global handler to integrate with your error reporting:
495
+
496
+ ```ts
497
+ import { configure } from 'mantle-lit';
498
+
499
+ configure({
500
+ onError: (error, context) => {
501
+ // context.phase: 'onCreate' | 'onMount' | 'onUnmount' | 'watch'
502
+ // context.name: class name of the View or Behavior
503
+ // context.isBehavior: true if the error came from a Behavior
504
+ Sentry.captureException(error, {
505
+ tags: { phase: context.phase, component: context.name },
506
+ });
507
+ },
508
+ });
509
+ ```
510
+
511
+ Behavior errors are isolated. A failing Behavior won't prevent sibling Behaviors or the parent View from mounting.
512
+
513
+ ## Behaviors (Experimental)
514
+
515
+ > ⚠️ **Experimental:** The Behaviors API is still evolving and may change in future releases.
516
+
517
+ 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.
518
+
519
+ ### Defining a Behavior
520
+
521
+ ```ts
522
+ import { Behavior, createBehavior } from 'mantle-lit';
523
+
524
+ class WindowSizeBehavior extends Behavior {
525
+ width = window.innerWidth;
526
+ height = window.innerHeight;
527
+ breakpoint!: number;
528
+
529
+ onCreate(breakpoint = 768) {
530
+ this.breakpoint = breakpoint;
531
+ }
532
+
533
+ get isMobile() {
534
+ return this.width < this.breakpoint;
535
+ }
536
+
537
+ handleResize() {
538
+ this.width = window.innerWidth;
539
+ this.height = window.innerHeight;
540
+ }
541
+
542
+ onMount() {
543
+ window.addEventListener('resize', this.handleResize);
544
+ return () => window.removeEventListener('resize', this.handleResize);
545
+ }
546
+ }
547
+
548
+ export const withWindowSize = createBehavior(WindowSizeBehavior);
549
+ ```
550
+
551
+ The naming convention:
552
+ - **Class**: PascalCase (`WindowSizeBehavior`)
553
+ - **Factory**: camelCase with `with` prefix (`withWindowSize`)
554
+
555
+ ### Using Behaviors
556
+
557
+ Call the factory function (no `new` keyword) in your View. The `with` prefix signals that the View manages this behavior's lifecycle:
558
+
559
+ ```ts
560
+ import { View, createView, html } from 'mantle-lit';
561
+ import { withWindowSize } from './withWindowSize';
562
+
563
+ class ResponsiveView extends View {
564
+ windowSize = withWindowSize(768);
565
+
566
+ render() {
567
+ return html`
568
+ <div>
569
+ ${this.windowSize.isMobile
570
+ ? html`<mobile-layout></mobile-layout>`
571
+ : html`<desktop-layout></desktop-layout>`}
572
+ <p>Window: ${this.windowSize.width}x${this.windowSize.height}</p>
573
+ </div>
574
+ `;
575
+ }
576
+ }
577
+
578
+ export const Responsive = createView(ResponsiveView, { tag: 'x-responsive' });
579
+ ```
580
+
581
+ ### Watching in Behaviors
582
+
583
+ Behaviors can use `this.watch` just like Views:
584
+
585
+ ```ts
586
+ class FetchBehavior extends Behavior {
587
+ url!: string;
588
+ data: any[] = [];
589
+ loading = false;
590
+
591
+ onCreate(url: string) {
592
+ this.url = url;
593
+ this.watch(() => this.url, () => this.fetchData(), { fireImmediately: true });
594
+ }
595
+
596
+ async fetchData() {
597
+ this.loading = true;
598
+ this.data = await fetch(this.url).then(r => r.json());
599
+ this.loading = false;
600
+ }
601
+ }
602
+
603
+ export const withFetch = createBehavior(FetchBehavior);
604
+ ```
605
+
606
+ ### Multiple Behaviors
607
+
608
+ Behaviors compose naturally:
609
+
610
+ ```ts
611
+ import { View, createView, html } from 'mantle-lit';
612
+ import { withFetch } from './FetchBehavior';
613
+ import { withWindowSize } from './WindowSizeBehavior';
614
+
615
+ class DashboardView extends View {
616
+ users = withFetch('/api/users');
617
+ posts = withFetch('/api/posts');
618
+ windowSize = withWindowSize(768);
619
+
620
+ render() {
621
+ return html`
622
+ <div>
623
+ ${this.users.loading ? 'Loading...' : `${this.users.data.length} users`}
624
+ ${this.windowSize.isMobile ? html`<mobile-nav></mobile-nav>` : ''}
625
+ </div>
626
+ `;
627
+ }
628
+ }
629
+
630
+ export const Dashboard = createView(DashboardView, { tag: 'x-dashboard' });
631
+ ```
632
+
633
+ ### Behavior Lifecycle
634
+
635
+ Behaviors support the same lifecycle methods as Views:
636
+
637
+ | Method | When |
638
+ |--------|------|
639
+ | `onCreate(...args)` | Called during construction with the factory arguments |
640
+ | `onMount()` | Called when parent View connects to DOM. Return cleanup (optional). |
641
+ | `onUnmount()` | Called when parent View disconnects from DOM. |
642
+
643
+
644
+ ## API
645
+
646
+ ### `configure(config)`
647
+
648
+ Set global defaults for all views. Settings can still be overridden per-view in `createView` options.
649
+
650
+ ```ts
651
+ import { configure } from 'mantle-lit';
652
+
653
+ // Disable auto-observable globally (for decorator users)
654
+ configure({ autoObservable: false });
655
+ ```
656
+
657
+ | Option | Default | Description |
658
+ |--------|---------|-------------|
659
+ | `autoObservable` | `true` | Whether to automatically make View instances observable |
660
+ | `onError` | `console.error` | Global error handler for lifecycle errors (see [Error Handling](#error-handling)) |
661
+
662
+ ### `View` / `ViewModel`
663
+
664
+ Base class for view components. Pure MobX state container—`createView()` generates the HTMLElement wrapper.
665
+
666
+ `View` and `ViewModel` are aliases. Use `View` for combined pattern (with `render()`), `ViewModel` for separated pattern (with external template).
667
+
668
+ | Property/Method | Description |
669
+ |-----------------|-------------|
670
+ | `onCreate()` | Called when instance created |
671
+ | `onMount()` | Called when connected to DOM, return cleanup (optional) |
672
+ | `onUnmount()` | Called when disconnected from DOM (optional) |
673
+ | `render()` | Optional. Return `TemplateResult`. If omitted, pass `template` to `createView()`. |
674
+ | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
675
+
676
+ ### `mount(tag, props, container)`
677
+
678
+ Imperatively create and mount a custom element:
679
+
680
+ ```ts
681
+ import { mount } from 'mantle-lit';
682
+
683
+ const element = mount('x-my-component', { title: 'Hello' }, document.body);
684
+ ```
685
+
686
+ | Argument | Type | Description |
687
+ |----------|------|-------------|
688
+ | `tag` | `string` | Custom element tag name |
689
+ | `props` | `object` | Properties to set on the element |
690
+ | `container` | `Element \| string` | Container element or selector |
691
+
692
+ Returns the created element.
693
+
694
+ ### `Behavior`
695
+
696
+ Base class for behaviors. Extend it and wrap with `createBehavior()`.
697
+
698
+ | Method | Description |
699
+ |--------|-------------|
700
+ | `onCreate(...args)` | Called during construction with constructor args |
701
+ | `onMount()` | Called when parent View mounts, return cleanup (optional) |
702
+ | `onUnmount()` | Called when parent View unmounts |
703
+ | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
704
+
705
+ ### `createBehavior(Class)`
706
+
707
+ Creates a factory function from a behavior class. Returns a callable (no `new` needed).
708
+
709
+ ```ts
710
+ class MyBehavior extends Behavior {
711
+ onCreate(value: string) { /* ... */ }
712
+ }
713
+
714
+ export const withMyBehavior = createBehavior(MyBehavior);
715
+
716
+ // Usage: withMyBehavior('hello')
717
+ ```
718
+
719
+ ### `createView(ViewClass, options)`
720
+
721
+ Creates a custom element from a ViewModel class.
722
+
723
+ ```ts
724
+ // Combined pattern (ViewModel has render method)
725
+ createView(MyView, { tag: 'x-my-view' })
726
+
727
+ // Separated pattern (external template)
728
+ createView(MyVM, {
729
+ tag: 'x-my-view',
730
+ template: (vm) => html`...`,
731
+ styles: css`...`,
732
+ })
733
+ ```
734
+
735
+ | Option | Default | Description |
736
+ |--------|---------|-------------|
737
+ | `tag` | (required) | Custom element tag name (must contain a hyphen) |
738
+ | `template` | — | Template function `(vm) => TemplateResult`. Required if ViewModel has no `render()`. |
739
+ | `styles` | — | CSS styles (can also be defined on `ViewModel.styles`) |
740
+ | `autoObservable` | `true` | Make all fields observable. Set to `false` when using decorators. |
741
+ | `shadow` | `true` | Use Shadow DOM. Set to `false` to render in light DOM (allows external CSS). |
742
+
743
+ ## Who This Is For
744
+
745
+ - Teams using MobX for state management
746
+ - Developers who prefer class-based components
747
+ - Projects building standards-compliant web components
748
+ - Anyone integrating vanilla JS libraries
749
+ - Teams wanting to share components across frameworks
750
+
751
+ ## License
752
+
753
+ MIT