@view-models/core 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/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/ViewModel.d.ts +101 -0
- package/dist/ViewModel.d.ts.map +1 -0
- package/dist/ViewModel.js +95 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/package.json +57 -0
- package/src/ViewModel.ts +122 -0
- package/src/index.ts +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sune Simonsen <sune@we-knowhow.dk>
|
|
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,261 @@
|
|
|
1
|
+
# @view-models/core
|
|
2
|
+
|
|
3
|
+
A lightweight, framework-agnostic library for building reactive view models with TypeScript. Separate your business logic from your UI framework with a simple, testable pattern.
|
|
4
|
+
|
|
5
|
+
## Why View Models?
|
|
6
|
+
|
|
7
|
+
- **Framework Agnostic**: Write your logic once, use it with React, Preact, or any other framework
|
|
8
|
+
- **Easy Testing**: Test your view logic without rendering anything to the DOM
|
|
9
|
+
- **Simple API**: Just extend `ViewModel` and you're ready to go
|
|
10
|
+
- **Type Safe**: Built with TypeScript for excellent IDE support
|
|
11
|
+
- **Minimal Boilerplate**: Much lighter than Redux with no reducers, actions, or middleware needed
|
|
12
|
+
- **Natural Organization**: A clear place to put your application logic
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @view-models/core
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
Define your state type and extend `ViewModel`:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { ViewModel } from "@view-models/core";
|
|
26
|
+
|
|
27
|
+
type CounterState = {
|
|
28
|
+
count: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
class CounterViewModel extends ViewModel<CounterState> {
|
|
32
|
+
constructor() {
|
|
33
|
+
super({ count: 0 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
increment() {
|
|
37
|
+
this.update(({ count }) => ({
|
|
38
|
+
count: count + 1,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
decrement() {
|
|
43
|
+
this.update(({ count }) => ({
|
|
44
|
+
count: count - 1,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reset() {
|
|
49
|
+
this.update(() => ({ count: 0 }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Use it in your tests:
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { describe, it, expect } from "vitest";
|
|
58
|
+
|
|
59
|
+
describe("CounterViewModel", () => {
|
|
60
|
+
it("increments the counter", () => {
|
|
61
|
+
const counter = new CounterViewModel();
|
|
62
|
+
|
|
63
|
+
counter.increment();
|
|
64
|
+
expect(counter.state.count).toBe(1);
|
|
65
|
+
|
|
66
|
+
counter.increment();
|
|
67
|
+
expect(counter.state.count).toBe(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("notifies subscribers on updates", () => {
|
|
71
|
+
const counter = new CounterViewModel();
|
|
72
|
+
const updates = [];
|
|
73
|
+
|
|
74
|
+
counter.subscribe((state) => updates.push(state));
|
|
75
|
+
|
|
76
|
+
counter.increment();
|
|
77
|
+
counter.increment();
|
|
78
|
+
|
|
79
|
+
expect(updates).toEqual([{ count: 1 }, { count: 2 }]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Framework Integration
|
|
85
|
+
|
|
86
|
+
The view models are designed to work with framework-specific adapters. Upcoming adapters include:
|
|
87
|
+
|
|
88
|
+
- **@view-models/react** - React hooks integration
|
|
89
|
+
- **@view-models/preact** - Preact hooks integration
|
|
90
|
+
|
|
91
|
+
These adapters will allow you to use the same view model with different frameworks:
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
// Coming soon with @view-models/react
|
|
95
|
+
function Counter({ model }) {
|
|
96
|
+
const { count } = useModelState(model);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div>
|
|
100
|
+
<p>Count: {count}</p>
|
|
101
|
+
<button onClick={model.increment}>+</button>
|
|
102
|
+
<button onClick={model.decrement}>-</button>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## API Reference
|
|
109
|
+
|
|
110
|
+
### `ViewModel<T>`
|
|
111
|
+
|
|
112
|
+
Abstract base class for creating view models.
|
|
113
|
+
|
|
114
|
+
#### Constructor
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
constructor(initialState: T)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Initialize the view model with an initial state.
|
|
121
|
+
|
|
122
|
+
#### Methods
|
|
123
|
+
|
|
124
|
+
##### `subscribe(listener: ViewModelListener<T>): void`
|
|
125
|
+
|
|
126
|
+
Subscribe to state changes. The listener will be called with the new state whenever `update()` is called.
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
const unsubscribe = viewModel.subscribe((state) => {
|
|
130
|
+
console.log("State changed:", state);
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
##### `unsubscribe(listener: ViewModelListener<T>): void`
|
|
135
|
+
|
|
136
|
+
Unsubscribe a previously subscribed listener.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
viewModel.unsubscribe(listener);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
##### `update(updater: Updater<T>): void` (protected)
|
|
143
|
+
|
|
144
|
+
Update the state and notify all subscribers. This method is protected and should only be called from within your view model subclass.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
protected update(updater: (currentState: T) => T): void
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The updater function receives the current state and should return the new state.
|
|
151
|
+
|
|
152
|
+
##### `state: T` (getter)
|
|
153
|
+
|
|
154
|
+
Access the current state.
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
const currentState = viewModel.state;
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Patterns and Best Practices
|
|
161
|
+
|
|
162
|
+
### Keep State Immutable
|
|
163
|
+
|
|
164
|
+
Always return new state objects from your updater functions:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// Good
|
|
168
|
+
this.update(({ count }) => ({
|
|
169
|
+
...state,
|
|
170
|
+
count: count + 1,
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
// Bad - mutates existing state
|
|
174
|
+
this.update((state) => {
|
|
175
|
+
state.count++;
|
|
176
|
+
return state;
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Use Readonly Types
|
|
181
|
+
|
|
182
|
+
TypeScript's `Readonly` and `ReadonlyArray` help ensure immutability:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
type State = {
|
|
186
|
+
items: ReadonlyArray<string>;
|
|
187
|
+
config: Readonly<{ enabled: boolean }>;
|
|
188
|
+
};
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Compose Multiple View Models
|
|
192
|
+
|
|
193
|
+
You can compose view models for complex UIs:
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
class AppViewModel {
|
|
197
|
+
readonly user = new UserViewModel();
|
|
198
|
+
readonly cart = new CartViewModel();
|
|
199
|
+
readonly products = new ProductsViewModel();
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Handle Side Effects
|
|
204
|
+
|
|
205
|
+
Keep your update logic pure, but expose methods for side effects:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
class TodosViewModel extends ViewModel<TodosState> {
|
|
209
|
+
private api: API
|
|
210
|
+
|
|
211
|
+
constructor(state: TodosState, api: API) {
|
|
212
|
+
super(state)
|
|
213
|
+
this.api = api
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async loadTodos() {
|
|
217
|
+
this.update((state) => ({ ...state, loading: true, failed: false }));
|
|
218
|
+
try {
|
|
219
|
+
const todos = await this.api.fetchTodos();
|
|
220
|
+
this.update(() => ({ todos, loading: false }));
|
|
221
|
+
} catch {
|
|
222
|
+
this.update((state) => ({ ...state, loading: false: failed: true }));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async addTodo(text: string) {
|
|
227
|
+
try {
|
|
228
|
+
const todo = await this.api.createTodo(text);
|
|
229
|
+
this.update(({ todos }) => ({
|
|
230
|
+
todos: [...todos, todo],
|
|
231
|
+
}));
|
|
232
|
+
} catch {
|
|
233
|
+
// TODO show error
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT License
|
|
242
|
+
|
|
243
|
+
Copyright (c) 2026 Sune Simonsen <sune@we-knowhow.dk>
|
|
244
|
+
|
|
245
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
246
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
247
|
+
in the Software without restriction, including without limitation the rights
|
|
248
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
249
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
250
|
+
furnished to do so, subject to the following conditions:
|
|
251
|
+
|
|
252
|
+
The above copyright notice and this permission notice shall be included in all
|
|
253
|
+
copies or substantial portions of the Software.
|
|
254
|
+
|
|
255
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
256
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
257
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
258
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
259
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
260
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
261
|
+
SOFTWARE.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
type State = Readonly<object>;
|
|
2
|
+
/**
|
|
3
|
+
* Function that receives the current state and returns the new state.
|
|
4
|
+
* The updater function should be pure and return a new state object.
|
|
5
|
+
*
|
|
6
|
+
* @template T - The state type
|
|
7
|
+
* @param currentState - The current state
|
|
8
|
+
* @returns The new state
|
|
9
|
+
*/
|
|
10
|
+
export type Updater<T extends State> = (currentState: T) => T;
|
|
11
|
+
/**
|
|
12
|
+
* Function that gets called when the state changes.
|
|
13
|
+
*
|
|
14
|
+
* @template T - The state type
|
|
15
|
+
* @param state - The new state
|
|
16
|
+
*/
|
|
17
|
+
export type ViewModelListener<T> = (state: T) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Abstract base class for creating reactive view models.
|
|
20
|
+
*
|
|
21
|
+
* A ViewModel manages state and notifies subscribers when the state changes.
|
|
22
|
+
* Extend this class to create your own view models with custom business logic.
|
|
23
|
+
*
|
|
24
|
+
* @template T - The state type (must be a readonly object)
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* type CounterState = { count: number };
|
|
29
|
+
*
|
|
30
|
+
* class CounterViewModel extends ViewModel<CounterState> {
|
|
31
|
+
* constructor() {
|
|
32
|
+
* super({ count: 0 });
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* increment() {
|
|
36
|
+
* this.update(({ count }) => ({ count: count + 1 }));
|
|
37
|
+
* }
|
|
38
|
+
* }
|
|
39
|
+
*
|
|
40
|
+
* const counter = new CounterViewModel();
|
|
41
|
+
* const unsubscribe = counter.subscribe((state) => {
|
|
42
|
+
* console.log('Count:', state.count);
|
|
43
|
+
* });
|
|
44
|
+
* counter.increment(); // Logs: Count: 1
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare abstract class ViewModel<T extends State> {
|
|
48
|
+
private _listeners;
|
|
49
|
+
/**
|
|
50
|
+
* Subscribe to state changes.
|
|
51
|
+
*
|
|
52
|
+
* The listener will be called immediately after any state update.
|
|
53
|
+
*
|
|
54
|
+
* @param listener - Function to call when state changes
|
|
55
|
+
* @returns Function to unsubscribe the listener
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```typescript
|
|
59
|
+
* const unsubscribe = viewModel.subscribe((state) => {
|
|
60
|
+
* console.log('State changed:', state);
|
|
61
|
+
* });
|
|
62
|
+
*
|
|
63
|
+
* // Later, when you want to stop listening:
|
|
64
|
+
* unsubscribe();
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
subscribe(listener: ViewModelListener<T>): () => void;
|
|
68
|
+
private _state;
|
|
69
|
+
/**
|
|
70
|
+
* Create a new ViewModel with the given initial state.
|
|
71
|
+
*
|
|
72
|
+
* @param initialState - The initial state of the view model
|
|
73
|
+
*/
|
|
74
|
+
constructor(initialState: T);
|
|
75
|
+
/**
|
|
76
|
+
* Update the state and notify all subscribers.
|
|
77
|
+
*
|
|
78
|
+
* This method is protected and should only be called from within your view model subclass.
|
|
79
|
+
* The updater function receives the current state and should return the new state.
|
|
80
|
+
* Always return a new state object to ensure immutability.
|
|
81
|
+
*
|
|
82
|
+
* @param updater - Function that receives current state and returns new state
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```typescript
|
|
86
|
+
* this.update((currentState) => ({
|
|
87
|
+
* ...currentState,
|
|
88
|
+
* count: currentState.count + 1
|
|
89
|
+
* }));
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
protected update(updater: Updater<T>): void;
|
|
93
|
+
/**
|
|
94
|
+
* Get the current state.
|
|
95
|
+
*
|
|
96
|
+
* @returns The current state
|
|
97
|
+
*/
|
|
98
|
+
get state(): T;
|
|
99
|
+
}
|
|
100
|
+
export {};
|
|
101
|
+
//# sourceMappingURL=ViewModel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ViewModel.d.ts","sourceRoot":"","sources":["../src/ViewModel.ts"],"names":[],"mappings":"AAAA,KAAK,KAAK,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC;AAE9B;;;;;;;GAOG;AACH,MAAM,MAAM,OAAO,CAAC,CAAC,SAAS,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC,KAAK,CAAC,CAAC;AAE9D;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,8BAAsB,SAAS,CAAC,CAAC,SAAS,KAAK;IAC7C,OAAO,CAAC,UAAU,CAAwC;IAE1D;;;;;;;;;;;;;;;;;OAiBG;IACH,SAAS,CAAC,QAAQ,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IAOrD,OAAO,CAAC,MAAM,CAAI;IAElB;;;;OAIG;gBACS,YAAY,EAAE,CAAC;IAI3B;;;;;;;;;;;;;;;;OAgBG;IACH,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC;IAQpC;;;;OAIG;IACH,IAAI,KAAK,IAAI,CAAC,CAEb;CACF"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for creating reactive view models.
|
|
3
|
+
*
|
|
4
|
+
* A ViewModel manages state and notifies subscribers when the state changes.
|
|
5
|
+
* Extend this class to create your own view models with custom business logic.
|
|
6
|
+
*
|
|
7
|
+
* @template T - The state type (must be a readonly object)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* type CounterState = { count: number };
|
|
12
|
+
*
|
|
13
|
+
* class CounterViewModel extends ViewModel<CounterState> {
|
|
14
|
+
* constructor() {
|
|
15
|
+
* super({ count: 0 });
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* increment() {
|
|
19
|
+
* this.update(({ count }) => ({ count: count + 1 }));
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* const counter = new CounterViewModel();
|
|
24
|
+
* const unsubscribe = counter.subscribe((state) => {
|
|
25
|
+
* console.log('Count:', state.count);
|
|
26
|
+
* });
|
|
27
|
+
* counter.increment(); // Logs: Count: 1
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class ViewModel {
|
|
31
|
+
/**
|
|
32
|
+
* Subscribe to state changes.
|
|
33
|
+
*
|
|
34
|
+
* The listener will be called immediately after any state update.
|
|
35
|
+
*
|
|
36
|
+
* @param listener - Function to call when state changes
|
|
37
|
+
* @returns Function to unsubscribe the listener
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const unsubscribe = viewModel.subscribe((state) => {
|
|
42
|
+
* console.log('State changed:', state);
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Later, when you want to stop listening:
|
|
46
|
+
* unsubscribe();
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
subscribe(listener) {
|
|
50
|
+
this._listeners.add(listener);
|
|
51
|
+
return () => {
|
|
52
|
+
this._listeners.delete(listener);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create a new ViewModel with the given initial state.
|
|
57
|
+
*
|
|
58
|
+
* @param initialState - The initial state of the view model
|
|
59
|
+
*/
|
|
60
|
+
constructor(initialState) {
|
|
61
|
+
this._listeners = new Set();
|
|
62
|
+
this._state = initialState;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Update the state and notify all subscribers.
|
|
66
|
+
*
|
|
67
|
+
* This method is protected and should only be called from within your view model subclass.
|
|
68
|
+
* The updater function receives the current state and should return the new state.
|
|
69
|
+
* Always return a new state object to ensure immutability.
|
|
70
|
+
*
|
|
71
|
+
* @param updater - Function that receives current state and returns new state
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* this.update((currentState) => ({
|
|
76
|
+
* ...currentState,
|
|
77
|
+
* count: currentState.count + 1
|
|
78
|
+
* }));
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
update(updater) {
|
|
82
|
+
this._state = updater(this._state);
|
|
83
|
+
for (const listener of this._listeners) {
|
|
84
|
+
listener(this._state);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get the current state.
|
|
89
|
+
*
|
|
90
|
+
* @returns The current state
|
|
91
|
+
*/
|
|
92
|
+
get state() {
|
|
93
|
+
return this._state;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./ViewModel.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@view-models/core",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight, framework-agnostic library for building reactive view models with TypeScript",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"view",
|
|
7
|
+
"viewmodel",
|
|
8
|
+
"reactive",
|
|
9
|
+
"state",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Sune Simonsen",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/sunesimonsen/view-models-core.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/sunesimonsen/view-models-core/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/sunesimonsen/view-models-core#readme",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"src"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:watch": "vitest",
|
|
43
|
+
"test:coverage": "vitest run --coverage",
|
|
44
|
+
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
|
|
45
|
+
"format:check": "prettier --check \"**/*.{ts,js,json,md}\"",
|
|
46
|
+
"prepublishOnly": "npm run build"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@vitest/coverage-v8": "^4.0.16",
|
|
50
|
+
"prettier": "^3.7.4",
|
|
51
|
+
"typescript": "^5.7.3",
|
|
52
|
+
"vitest": "^4.0.16"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/ViewModel.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
type State = Readonly<object>;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Function that receives the current state and returns the new state.
|
|
5
|
+
* The updater function should be pure and return a new state object.
|
|
6
|
+
*
|
|
7
|
+
* @template T - The state type
|
|
8
|
+
* @param currentState - The current state
|
|
9
|
+
* @returns The new state
|
|
10
|
+
*/
|
|
11
|
+
export type Updater<T extends State> = (currentState: T) => T;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Function that gets called when the state changes.
|
|
15
|
+
*
|
|
16
|
+
* @template T - The state type
|
|
17
|
+
* @param state - The new state
|
|
18
|
+
*/
|
|
19
|
+
export type ViewModelListener<T> = (state: T) => void;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Abstract base class for creating reactive view models.
|
|
23
|
+
*
|
|
24
|
+
* A ViewModel manages state and notifies subscribers when the state changes.
|
|
25
|
+
* Extend this class to create your own view models with custom business logic.
|
|
26
|
+
*
|
|
27
|
+
* @template T - The state type (must be a readonly object)
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* type CounterState = { count: number };
|
|
32
|
+
*
|
|
33
|
+
* class CounterViewModel extends ViewModel<CounterState> {
|
|
34
|
+
* constructor() {
|
|
35
|
+
* super({ count: 0 });
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* increment() {
|
|
39
|
+
* this.update(({ count }) => ({ count: count + 1 }));
|
|
40
|
+
* }
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* const counter = new CounterViewModel();
|
|
44
|
+
* const unsubscribe = counter.subscribe((state) => {
|
|
45
|
+
* console.log('Count:', state.count);
|
|
46
|
+
* });
|
|
47
|
+
* counter.increment(); // Logs: Count: 1
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export abstract class ViewModel<T extends State> {
|
|
51
|
+
private _listeners: Set<ViewModelListener<T>> = new Set();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe to state changes.
|
|
55
|
+
*
|
|
56
|
+
* The listener will be called immediately after any state update.
|
|
57
|
+
*
|
|
58
|
+
* @param listener - Function to call when state changes
|
|
59
|
+
* @returns Function to unsubscribe the listener
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```typescript
|
|
63
|
+
* const unsubscribe = viewModel.subscribe((state) => {
|
|
64
|
+
* console.log('State changed:', state);
|
|
65
|
+
* });
|
|
66
|
+
*
|
|
67
|
+
* // Later, when you want to stop listening:
|
|
68
|
+
* unsubscribe();
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
subscribe(listener: ViewModelListener<T>): () => void {
|
|
72
|
+
this._listeners.add(listener);
|
|
73
|
+
return () => {
|
|
74
|
+
this._listeners.delete(listener);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private _state: T;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a new ViewModel with the given initial state.
|
|
82
|
+
*
|
|
83
|
+
* @param initialState - The initial state of the view model
|
|
84
|
+
*/
|
|
85
|
+
constructor(initialState: T) {
|
|
86
|
+
this._state = initialState;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Update the state and notify all subscribers.
|
|
91
|
+
*
|
|
92
|
+
* This method is protected and should only be called from within your view model subclass.
|
|
93
|
+
* The updater function receives the current state and should return the new state.
|
|
94
|
+
* Always return a new state object to ensure immutability.
|
|
95
|
+
*
|
|
96
|
+
* @param updater - Function that receives current state and returns new state
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* this.update((currentState) => ({
|
|
101
|
+
* ...currentState,
|
|
102
|
+
* count: currentState.count + 1
|
|
103
|
+
* }));
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
protected update(updater: Updater<T>) {
|
|
107
|
+
this._state = updater(this._state);
|
|
108
|
+
|
|
109
|
+
for (const listener of this._listeners) {
|
|
110
|
+
listener(this._state);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get the current state.
|
|
116
|
+
*
|
|
117
|
+
* @returns The current state
|
|
118
|
+
*/
|
|
119
|
+
get state(): T {
|
|
120
|
+
return this._state;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./ViewModel.js";
|