mvc-kit 2.12.0 → 2.12.2
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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +19 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { Service } from './Service';
|
|
3
|
+
import { singleton, teardown, teardownAll } from './singleton';
|
|
4
|
+
|
|
5
|
+
class ApiService extends Service {
|
|
6
|
+
onDisposeCalled = false;
|
|
7
|
+
|
|
8
|
+
protected onDispose(): void {
|
|
9
|
+
this.onDisposeCalled = true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async fetchData(): Promise<string> {
|
|
13
|
+
return 'data';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class StorageService extends Service {
|
|
18
|
+
private data: Map<string, string> = new Map();
|
|
19
|
+
|
|
20
|
+
set(key: string, value: string): void {
|
|
21
|
+
this.data.set(key, value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(key: string): string | undefined {
|
|
25
|
+
return this.data.get(key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
protected onDispose(): void {
|
|
29
|
+
this.data.clear();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('Service', () => {
|
|
34
|
+
describe('initialization', () => {
|
|
35
|
+
it('starts not disposed', () => {
|
|
36
|
+
const service = new ApiService();
|
|
37
|
+
expect(service.disposed).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('dispose', () => {
|
|
42
|
+
it('sets disposed to true', () => {
|
|
43
|
+
const service = new ApiService();
|
|
44
|
+
service.dispose();
|
|
45
|
+
expect(service.disposed).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('calls onDispose hook', () => {
|
|
49
|
+
const service = new ApiService();
|
|
50
|
+
expect(service.onDisposeCalled).toBe(false);
|
|
51
|
+
service.dispose();
|
|
52
|
+
expect(service.onDisposeCalled).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('is idempotent', () => {
|
|
56
|
+
let callCount = 0;
|
|
57
|
+
class CountingService extends Service {
|
|
58
|
+
protected onDispose(): void {
|
|
59
|
+
callCount++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const service = new CountingService();
|
|
63
|
+
service.dispose();
|
|
64
|
+
service.dispose();
|
|
65
|
+
service.dispose();
|
|
66
|
+
expect(callCount).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('init', () => {
|
|
71
|
+
it('starts not initialized', () => {
|
|
72
|
+
const service = new ApiService();
|
|
73
|
+
expect(service.initialized).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('sets initialized to true after init()', () => {
|
|
77
|
+
const service = new ApiService();
|
|
78
|
+
service.init();
|
|
79
|
+
expect(service.initialized).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('calls onInit hook', () => {
|
|
83
|
+
let called = false;
|
|
84
|
+
class InitService extends Service {
|
|
85
|
+
protected onInit() {
|
|
86
|
+
called = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const service = new InitService();
|
|
90
|
+
service.init();
|
|
91
|
+
expect(called).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('is idempotent — onInit called only once', () => {
|
|
95
|
+
let callCount = 0;
|
|
96
|
+
class CountingService extends Service {
|
|
97
|
+
protected onInit() {
|
|
98
|
+
callCount++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const service = new CountingService();
|
|
102
|
+
service.init();
|
|
103
|
+
service.init();
|
|
104
|
+
service.init();
|
|
105
|
+
expect(callCount).toBe(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('supports async onInit', async () => {
|
|
109
|
+
let resolved = false;
|
|
110
|
+
class AsyncService extends Service {
|
|
111
|
+
protected async onInit() {
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
resolved = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const service = new AsyncService();
|
|
117
|
+
await service.init();
|
|
118
|
+
expect(resolved).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('is a no-op after dispose', () => {
|
|
122
|
+
let called = false;
|
|
123
|
+
class InitService extends Service {
|
|
124
|
+
protected onInit() {
|
|
125
|
+
called = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const service = new InitService();
|
|
129
|
+
service.dispose();
|
|
130
|
+
service.init();
|
|
131
|
+
expect(called).toBe(false);
|
|
132
|
+
expect(service.initialized).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('methods', () => {
|
|
137
|
+
it('can perform operations', async () => {
|
|
138
|
+
const service = new ApiService();
|
|
139
|
+
const result = await service.fetchData();
|
|
140
|
+
expect(result).toBe('data');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('can manage state', () => {
|
|
144
|
+
const service = new StorageService();
|
|
145
|
+
service.set('key', 'value');
|
|
146
|
+
expect(service.get('key')).toBe('value');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('singleton integration', () => {
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
teardownAll();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('can be used with singleton registry', () => {
|
|
156
|
+
const s1 = singleton(ApiService);
|
|
157
|
+
const s2 = singleton(ApiService);
|
|
158
|
+
expect(s1).toBe(s2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('maintains separate singletons per class', () => {
|
|
162
|
+
const api = singleton(ApiService);
|
|
163
|
+
const storage = singleton(StorageService);
|
|
164
|
+
expect(api).not.toBe(storage);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('can be torn down', () => {
|
|
168
|
+
const service = singleton(ApiService);
|
|
169
|
+
teardown(ApiService);
|
|
170
|
+
expect(service.disposed).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('signal and addCleanup', () => {
|
|
175
|
+
it('signal returns an AbortSignal', () => {
|
|
176
|
+
const service = new ApiService();
|
|
177
|
+
expect(service.disposeSignal).toBeInstanceOf(AbortSignal);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns the same signal on multiple accesses', () => {
|
|
181
|
+
const service = new ApiService();
|
|
182
|
+
const s1 = service.disposeSignal;
|
|
183
|
+
const s2 = service.disposeSignal;
|
|
184
|
+
expect(s1).toBe(s2);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('signal is not aborted before dispose', () => {
|
|
188
|
+
const service = new ApiService();
|
|
189
|
+
expect(service.disposeSignal.aborted).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('signal is aborted after dispose', () => {
|
|
193
|
+
const service = new ApiService();
|
|
194
|
+
const signal = service.disposeSignal;
|
|
195
|
+
service.dispose();
|
|
196
|
+
expect(signal.aborted).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('signal is aborted before onDispose runs', () => {
|
|
200
|
+
let wasAbortedDuringDispose = false;
|
|
201
|
+
class CheckService extends Service {
|
|
202
|
+
protected onDispose(): void {
|
|
203
|
+
wasAbortedDuringDispose = this.disposeSignal.aborted;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const service = new CheckService();
|
|
207
|
+
service.disposeSignal; // force lazy creation
|
|
208
|
+
service.dispose();
|
|
209
|
+
expect(wasAbortedDuringDispose).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('addCleanup fires on dispose', () => {
|
|
213
|
+
let cleaned = false;
|
|
214
|
+
class CleanupService extends Service {
|
|
215
|
+
setup() {
|
|
216
|
+
this.addCleanup(() => { cleaned = true; });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const service = new CleanupService();
|
|
220
|
+
service.setup();
|
|
221
|
+
expect(cleaned).toBe(false);
|
|
222
|
+
service.dispose();
|
|
223
|
+
expect(cleaned).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('dispose works without accessing signal (lazy, zero cost)', () => {
|
|
227
|
+
const service = new ApiService();
|
|
228
|
+
service.dispose();
|
|
229
|
+
expect(service.disposed).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('method binding', () => {
|
|
234
|
+
it('subclass methods work point-free', () => {
|
|
235
|
+
const svc = new StorageService();
|
|
236
|
+
const { set, get } = svc;
|
|
237
|
+
set('key', 'value');
|
|
238
|
+
expect(get('key')).toBe('value');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
package/src/Service.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Disposable } from './types';
|
|
2
|
+
import { bindPublicMethods } from './bindPublicMethods';
|
|
3
|
+
|
|
4
|
+
const PROTECTED_KEYS = new Set(['addCleanup']);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base class for non-reactive infrastructure services.
|
|
8
|
+
* Services encapsulate external dependencies like APIs, storage, etc.
|
|
9
|
+
*/
|
|
10
|
+
export abstract class Service implements Disposable {
|
|
11
|
+
private _disposed = false;
|
|
12
|
+
private _initialized = false;
|
|
13
|
+
private _abortController: AbortController | null = null;
|
|
14
|
+
private _cleanups: (() => void)[] | null = null;
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
bindPublicMethods(this, Object.prototype, PROTECTED_KEYS);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether this instance has been disposed. */
|
|
21
|
+
get disposed(): boolean {
|
|
22
|
+
return this._disposed;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Whether init() has been called. */
|
|
26
|
+
get initialized(): boolean {
|
|
27
|
+
return this._initialized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** AbortSignal that fires when this instance is disposed. Lazily created. */
|
|
31
|
+
get disposeSignal(): AbortSignal {
|
|
32
|
+
if (!this._abortController) {
|
|
33
|
+
this._abortController = new AbortController();
|
|
34
|
+
}
|
|
35
|
+
return this._abortController.signal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Initializes the instance. Called automatically by React hooks after mount. */
|
|
39
|
+
init(): void | Promise<void> {
|
|
40
|
+
if (this._initialized || this._disposed) return;
|
|
41
|
+
this._initialized = true;
|
|
42
|
+
return this.onInit?.();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tears down the instance, releasing all subscriptions and resources. */
|
|
46
|
+
dispose(): void {
|
|
47
|
+
if (this._disposed) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._disposed = true;
|
|
52
|
+
this._abortController?.abort();
|
|
53
|
+
if (this._cleanups) {
|
|
54
|
+
for (const fn of this._cleanups) fn();
|
|
55
|
+
this._cleanups = null;
|
|
56
|
+
}
|
|
57
|
+
this.onDispose?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Registers a cleanup function to be called on dispose. @protected */
|
|
61
|
+
protected addCleanup(fn: () => void): void {
|
|
62
|
+
if (!this._cleanups) {
|
|
63
|
+
this._cleanups = [];
|
|
64
|
+
}
|
|
65
|
+
this._cleanups.push(fn);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Lifecycle hook called at the end of init(). Override to load initial data. @protected */
|
|
69
|
+
protected onInit?(): void | Promise<void>;
|
|
70
|
+
/** Lifecycle hook called during dispose(). Override for custom teardown. @protected */
|
|
71
|
+
protected onDispose?(): void;
|
|
72
|
+
}
|
package/src/Sorting.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Sorting
|
|
2
|
+
|
|
3
|
+
Multi-column sorting helper. Manages sort descriptors with a 3-click toggle cycle and applies sorting to arrays. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use
|
|
8
|
+
|
|
9
|
+
Use Sorting as a ViewModel property to manage table/list sort state. Auto-tracking ensures ViewModel getters that read from Sorting auto-invalidate when sort state changes.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Creating a Sorting Instance
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { Sorting } from 'mvc-kit';
|
|
17
|
+
|
|
18
|
+
// No initial sorting
|
|
19
|
+
readonly sorting = new Sorting<User>();
|
|
20
|
+
|
|
21
|
+
// With initial sort
|
|
22
|
+
readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The generic parameter `<T>` is the type of items you'll sort. It's used by `apply()` and `compareFn`.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## API
|
|
30
|
+
|
|
31
|
+
### Readable State
|
|
32
|
+
|
|
33
|
+
#### `sorts: readonly SortDescriptor[]`
|
|
34
|
+
|
|
35
|
+
Full list of active sort descriptors, in priority order.
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
interface SortDescriptor {
|
|
39
|
+
key: string;
|
|
40
|
+
direction: 'asc' | 'desc';
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### `key: string | null`
|
|
45
|
+
|
|
46
|
+
Primary sort key (first descriptor), or `null` when empty.
|
|
47
|
+
|
|
48
|
+
#### `direction: 'asc' | 'desc'`
|
|
49
|
+
|
|
50
|
+
Primary sort direction. Defaults to `'asc'` when empty.
|
|
51
|
+
|
|
52
|
+
### Query Methods
|
|
53
|
+
|
|
54
|
+
#### `isSorted(key: string): boolean`
|
|
55
|
+
|
|
56
|
+
Whether the given key has an active sort descriptor.
|
|
57
|
+
|
|
58
|
+
#### `directionOf(key: string): 'asc' | 'desc' | null`
|
|
59
|
+
|
|
60
|
+
Direction of the given key, or `null` if not sorted.
|
|
61
|
+
|
|
62
|
+
#### `indexOf(key: string): number`
|
|
63
|
+
|
|
64
|
+
Index of the key in the sort list, or `-1` if not sorted.
|
|
65
|
+
|
|
66
|
+
### Actions
|
|
67
|
+
|
|
68
|
+
#### `toggle(key: string): void`
|
|
69
|
+
|
|
70
|
+
3-click cycle: **not sorted -> asc -> desc -> removed**.
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
sorting.toggle('name'); // [{key:'name', dir:'asc'}]
|
|
74
|
+
sorting.toggle('name'); // [{key:'name', dir:'desc'}]
|
|
75
|
+
sorting.toggle('name'); // [] (removed)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Multi-column: toggling a new key appends it. Toggling an existing key advances its cycle without affecting others.
|
|
79
|
+
|
|
80
|
+
#### `setSort(key: string, direction: 'asc' | 'desc'): void`
|
|
81
|
+
|
|
82
|
+
Replace all sorts with a single sort descriptor.
|
|
83
|
+
|
|
84
|
+
#### `setSorts(sorts: SortDescriptor[]): void`
|
|
85
|
+
|
|
86
|
+
Replace all sorts with the given descriptors.
|
|
87
|
+
|
|
88
|
+
#### `reset(): void`
|
|
89
|
+
|
|
90
|
+
Clear all sort descriptors.
|
|
91
|
+
|
|
92
|
+
### Pipeline
|
|
93
|
+
|
|
94
|
+
#### `apply(items: T[], compareFn?): T[]`
|
|
95
|
+
|
|
96
|
+
Returns a new sorted array (does not mutate the input).
|
|
97
|
+
|
|
98
|
+
Default comparison: `localeCompare` for strings, `<`/`>` for others, `null` values sort first.
|
|
99
|
+
|
|
100
|
+
Custom `compareFn` signature:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
(a: T, b: T, key: string, dir: 'asc' | 'desc') => number
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Subscribable Interface
|
|
107
|
+
|
|
108
|
+
#### `subscribe(cb: () => void): () => void`
|
|
109
|
+
|
|
110
|
+
Subscribe to state changes. Returns an unsubscribe function.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## ViewModel Integration
|
|
115
|
+
|
|
116
|
+
Declare as a ViewModel instance property. Auto-tracking handles the rest:
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
class UsersVM extends ViewModel<FilterState> {
|
|
120
|
+
readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
|
|
121
|
+
private users = singleton(UsersResource);
|
|
122
|
+
|
|
123
|
+
get sorted(): User[] {
|
|
124
|
+
// Auto-invalidates when sorting changes
|
|
125
|
+
return this.sorting.apply(this.users.items);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## React Usage
|
|
133
|
+
|
|
134
|
+
Pass the Sorting helper directly to DataTable:
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
<DataTable
|
|
138
|
+
items={vm.items}
|
|
139
|
+
columns={columns}
|
|
140
|
+
sort={vm.sorting}
|
|
141
|
+
/>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Or use the object-literal form for custom usage:
|
|
145
|
+
|
|
146
|
+
```tsx
|
|
147
|
+
<DataTable
|
|
148
|
+
items={vm.items}
|
|
149
|
+
columns={columns}
|
|
150
|
+
sort={vm.sorting.sorts}
|
|
151
|
+
onSort={key => vm.sorting.toggle(key)}
|
|
152
|
+
/>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Or with plain buttons:
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
<button onClick={() => vm.sorting.toggle('name')}>
|
|
159
|
+
Name {vm.sorting.directionOf('name') === 'asc' ? '↑' : vm.sorting.directionOf('name') === 'desc' ? '↓' : ''}
|
|
160
|
+
</button>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Method Binding
|
|
164
|
+
|
|
165
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
const { toggle } = sorting; // destructure works
|
|
169
|
+
<SortHeader onClick={toggle} /> // point-free works
|
|
170
|
+
```
|