mvc-kit 2.7.1 → 2.9.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.
Files changed (129) hide show
  1. package/README.md +47 -1
  2. package/agent-config/claude-code/skills/guide/SKILL.md +1 -0
  3. package/agent-config/claude-code/skills/guide/anti-patterns.md +3 -3
  4. package/agent-config/claude-code/skills/guide/api-reference.md +146 -2
  5. package/agent-config/claude-code/skills/guide/patterns.md +120 -0
  6. package/agent-config/claude-code/skills/scaffold/templates/model.md +38 -1
  7. package/agent-config/copilot/copilot-instructions.md +54 -1
  8. package/agent-config/cursor/cursorrules +54 -1
  9. package/dist/Collection.cjs +69 -17
  10. package/dist/Collection.cjs.map +1 -1
  11. package/dist/Collection.d.ts.map +1 -1
  12. package/dist/Collection.js +69 -17
  13. package/dist/Collection.js.map +1 -1
  14. package/dist/Feed.cjs +86 -0
  15. package/dist/Feed.cjs.map +1 -0
  16. package/dist/Feed.d.ts +46 -0
  17. package/dist/Feed.d.ts.map +1 -0
  18. package/dist/Feed.js +86 -0
  19. package/dist/Feed.js.map +1 -0
  20. package/dist/Model.cjs +22 -4
  21. package/dist/Model.cjs.map +1 -1
  22. package/dist/Model.d.ts +2 -0
  23. package/dist/Model.d.ts.map +1 -1
  24. package/dist/Model.js +22 -4
  25. package/dist/Model.js.map +1 -1
  26. package/dist/Pagination.cjs +84 -0
  27. package/dist/Pagination.cjs.map +1 -0
  28. package/dist/Pagination.d.ts +39 -0
  29. package/dist/Pagination.d.ts.map +1 -0
  30. package/dist/Pagination.js +84 -0
  31. package/dist/Pagination.js.map +1 -0
  32. package/dist/PersistentCollection.cjs +16 -15
  33. package/dist/PersistentCollection.cjs.map +1 -1
  34. package/dist/PersistentCollection.d.ts +7 -1
  35. package/dist/PersistentCollection.d.ts.map +1 -1
  36. package/dist/PersistentCollection.js +16 -15
  37. package/dist/PersistentCollection.js.map +1 -1
  38. package/dist/Resource.cjs +23 -156
  39. package/dist/Resource.cjs.map +1 -1
  40. package/dist/Resource.d.ts +3 -2
  41. package/dist/Resource.d.ts.map +1 -1
  42. package/dist/Resource.js +23 -156
  43. package/dist/Resource.js.map +1 -1
  44. package/dist/Selection.cjs +99 -0
  45. package/dist/Selection.cjs.map +1 -0
  46. package/dist/Selection.d.ts +36 -0
  47. package/dist/Selection.d.ts.map +1 -0
  48. package/dist/Selection.js +99 -0
  49. package/dist/Selection.js.map +1 -0
  50. package/dist/Sorting.cjs +114 -0
  51. package/dist/Sorting.cjs.map +1 -0
  52. package/dist/Sorting.d.ts +43 -0
  53. package/dist/Sorting.d.ts.map +1 -0
  54. package/dist/Sorting.js +114 -0
  55. package/dist/Sorting.js.map +1 -0
  56. package/dist/ViewModel.cjs +177 -227
  57. package/dist/ViewModel.cjs.map +1 -1
  58. package/dist/ViewModel.d.ts +9 -12
  59. package/dist/ViewModel.d.ts.map +1 -1
  60. package/dist/ViewModel.js +177 -227
  61. package/dist/ViewModel.js.map +1 -1
  62. package/dist/index.d.ts +6 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/mvc-kit.cjs +8 -0
  65. package/dist/mvc-kit.cjs.map +1 -1
  66. package/dist/mvc-kit.js +8 -0
  67. package/dist/mvc-kit.js.map +1 -1
  68. package/dist/react/components/CardList.cjs +42 -0
  69. package/dist/react/components/CardList.cjs.map +1 -0
  70. package/dist/react/components/CardList.d.ts +22 -0
  71. package/dist/react/components/CardList.d.ts.map +1 -0
  72. package/dist/react/components/CardList.js +42 -0
  73. package/dist/react/components/CardList.js.map +1 -0
  74. package/dist/react/components/DataTable.cjs +179 -0
  75. package/dist/react/components/DataTable.cjs.map +1 -0
  76. package/dist/react/components/DataTable.d.ts +30 -0
  77. package/dist/react/components/DataTable.d.ts.map +1 -0
  78. package/dist/react/components/DataTable.js +179 -0
  79. package/dist/react/components/DataTable.js.map +1 -0
  80. package/dist/react/components/InfiniteScroll.cjs +44 -0
  81. package/dist/react/components/InfiniteScroll.cjs.map +1 -0
  82. package/dist/react/components/InfiniteScroll.d.ts +21 -0
  83. package/dist/react/components/InfiniteScroll.d.ts.map +1 -0
  84. package/dist/react/components/InfiniteScroll.js +44 -0
  85. package/dist/react/components/InfiniteScroll.js.map +1 -0
  86. package/dist/react/components/types.cjs +15 -0
  87. package/dist/react/components/types.cjs.map +1 -0
  88. package/dist/react/components/types.d.ts +71 -0
  89. package/dist/react/components/types.d.ts.map +1 -0
  90. package/dist/react/components/types.js +15 -0
  91. package/dist/react/components/types.js.map +1 -0
  92. package/dist/react/index.d.ts +8 -1
  93. package/dist/react/index.d.ts.map +1 -1
  94. package/dist/react/use-instance.cjs +31 -21
  95. package/dist/react/use-instance.cjs.map +1 -1
  96. package/dist/react/use-instance.d.ts +1 -1
  97. package/dist/react/use-instance.d.ts.map +1 -1
  98. package/dist/react/use-instance.js +32 -22
  99. package/dist/react/use-instance.js.map +1 -1
  100. package/dist/react/use-model.cjs +29 -2
  101. package/dist/react/use-model.cjs.map +1 -1
  102. package/dist/react/use-model.d.ts +9 -0
  103. package/dist/react/use-model.d.ts.map +1 -1
  104. package/dist/react/use-model.js +30 -3
  105. package/dist/react/use-model.js.map +1 -1
  106. package/dist/react-native/NativeCollection.cjs +3 -0
  107. package/dist/react-native/NativeCollection.cjs.map +1 -1
  108. package/dist/react-native/NativeCollection.d.ts +3 -0
  109. package/dist/react-native/NativeCollection.d.ts.map +1 -1
  110. package/dist/react-native/NativeCollection.js +3 -0
  111. package/dist/react-native/NativeCollection.js.map +1 -1
  112. package/dist/react.cjs +7 -0
  113. package/dist/react.cjs.map +1 -1
  114. package/dist/react.js +8 -1
  115. package/dist/react.js.map +1 -1
  116. package/dist/walkPrototypeChain.cjs.map +1 -1
  117. package/dist/walkPrototypeChain.d.ts +2 -2
  118. package/dist/walkPrototypeChain.js.map +1 -1
  119. package/dist/web/idb.cjs.map +1 -1
  120. package/dist/web/idb.d.ts +18 -0
  121. package/dist/web/idb.d.ts.map +1 -1
  122. package/dist/web/idb.js.map +1 -1
  123. package/dist/wrapAsyncMethods.cjs +159 -0
  124. package/dist/wrapAsyncMethods.cjs.map +1 -0
  125. package/dist/wrapAsyncMethods.d.ts +37 -0
  126. package/dist/wrapAsyncMethods.d.ts.map +1 -0
  127. package/dist/wrapAsyncMethods.js +159 -0
  128. package/dist/wrapAsyncMethods.js.map +1 -0
  129. package/package.json +1 -1
package/README.md CHANGED
@@ -666,6 +666,22 @@ function UserForm() {
666
666
  }
667
667
  ```
668
668
 
669
+ #### `useModelRef(factory)`
670
+
671
+ Create component-scoped Model with lifecycle management (init + dispose) but **no subscription**. The parent never re-renders from model state changes. Use with `useField` for per-field isolation in large forms.
672
+
673
+ ```tsx
674
+ function UserForm() {
675
+ const model = useModelRef(() => new UserModel({ name: '', email: '' }));
676
+ return (
677
+ <form>
678
+ <NameField model={model} />
679
+ <FormActions model={model} />
680
+ </form>
681
+ );
682
+ }
683
+ ```
684
+
669
685
  #### `useField(model, key)`
670
686
 
671
687
  Subscribe to a single field with surgical re-renders. The returned `set()` calls the Model's `set()` directly — use custom setter methods on the Model for any logic beyond simple assignment.
@@ -766,6 +782,15 @@ function App() {
766
782
  | `Service` | Non-reactive infrastructure service (Disposable) |
767
783
  | `EventBus<E>` | Typed pub/sub event bus |
768
784
 
785
+ ### Composable Helpers
786
+
787
+ | Class | Description |
788
+ |-------|-------------|
789
+ | `Sorting<T>` | Multi-column sort state with 3-click toggle cycle and `apply()` pipeline |
790
+ | `Pagination` | Page/pageSize state with `apply()` slicing |
791
+ | `Selection<K>` | Key-based selection set with toggle/select-all semantics |
792
+ | `Feed<T>` | Cursor + hasMore + item accumulation for server-side pagination |
793
+
769
794
  ### Interfaces
770
795
 
771
796
  | Interface | Description |
@@ -806,12 +831,21 @@ function App() {
806
831
  | `useLocal(Class \| factory, ...args)` | Component-scoped, auto-disposed, auto-init |
807
832
  | `useSingleton(Class, ...args)` | Singleton, registry-managed, auto-init |
808
833
  | `useModel(factory)` | Model with validation/dirty state, auto-init |
834
+ | `useModelRef(factory)` | Model lifecycle only (no subscription). For per-field forms. |
809
835
  | `useField(model, key)` | Single field subscription |
810
836
  | `useEvent(source, event, handler)` | Subscribe to EventBus or ViewModel event |
811
837
  | `useEmit(bus)` | Get stable emit function |
812
838
  | `useResolve(Class, ...args)` | Resolve from Provider or singleton |
813
839
  | `useTeardown(...Classes)` | Teardown singletons on unmount |
814
840
 
841
+ ### Headless React Components
842
+
843
+ | Component | Description |
844
+ |-----------|-------------|
845
+ | `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helper instances directly |
846
+ | `CardList<T>` | Unstyled list/grid with render-prop items |
847
+ | `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
848
+
815
849
  ## Behavior Notes
816
850
 
817
851
  - State is always shallow-frozen with `Object.freeze()`
@@ -856,6 +890,10 @@ Each core class and React hook has a dedicated reference doc with full API detai
856
890
  | [EventBus](src/EventBus.md) | Typed pub/sub for cross-cutting event communication |
857
891
  | [Channel](src/Channel.md) | Persistent connections (WebSocket, SSE) with auto-reconnect |
858
892
  | [Singleton Registry](src/singleton.md) | Global instance management: `singleton()`, `teardown()`, `teardownAll()` |
893
+ | [Sorting](src/Sorting.md) | Multi-column sort state with 3-click toggle cycle and apply pipeline |
894
+ | [Pagination](src/Pagination.md) | Page/pageSize state with array slicing |
895
+ | [Selection](src/Selection.md) | Key-based selection set with toggle/select-all |
896
+ | [Feed](src/Feed.md) | Cursor + hasMore + item accumulation for server-side pagination |
859
897
 
860
898
  **React Hooks**
861
899
 
@@ -864,10 +902,18 @@ Each core class and React hook has a dedicated reference doc with full API detai
864
902
  | [useLocal](src/react/use-local.md) | Component-scoped instance, auto-init/dispose, deps array for recreate |
865
903
  | [useInstance](src/react/use-instance.md) | Subscribe to an existing Subscribable (no lifecycle management) |
866
904
  | [useSingleton](src/react/use-singleton.md) | Singleton resolution with auto-init and shared state |
867
- | [useModel & useField](src/react/use-model.md) | Model binding with validation/dirty state; surgical per-field subscriptions |
905
+ | [useModel, useModelRef & useField](src/react/use-model.md) | Model binding with validation/dirty state; lifecycle-only ref; surgical per-field subscriptions |
868
906
  | [useEvent & useEmit](src/react/use-event-bus.md) | Subscribe to and emit typed events from EventBus or ViewModel |
869
907
  | [useTeardown](src/react/use-teardown.md) | Dispose singleton instances on component unmount |
870
908
 
909
+ **Headless Components**
910
+
911
+ | Doc | Description |
912
+ |-----|-------------|
913
+ | [DataTable](src/react/components/DataTable.md) | Unstyled table with sort, selection, pagination; accepts helpers directly |
914
+ | [CardList](src/react/components/CardList.md) | Unstyled list/grid with render-prop items |
915
+ | [InfiniteScroll](src/react/components/InfiniteScroll.md) | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
916
+
871
917
  ## Dev Mode (`__MVC_KIT_DEV__`)
872
918
 
873
919
  mvc-kit includes development-only safety checks guarded by the `__MVC_KIT_DEV__` flag. When enabled, these checks catch common mistakes at development time with clear `console.error` messages instead of silent infinite loops or hard-to-debug failures.
@@ -53,6 +53,7 @@ Private fields → Computed getters → Lifecycle → Actions → Setters
53
53
  | `useSingleton(Class, ...args)` | Singleton instance, shared state |
54
54
  | `useInstance(subscribable)` | Subscribe to existing instance |
55
55
  | `useModel(factory)` | Model with validation/dirty state |
56
+ | `useModelRef(factory)` | Model lifecycle only, no subscription. For per-field forms. |
56
57
  | `useField(model, key)` | Single field subscription |
57
58
  | `useEvent(source, event, handler)` | Subscribe to EventBus or ViewModel event |
58
59
  | `useEmit(bus)` | Stable emit function |
@@ -433,7 +433,7 @@ async load() {
433
433
 
434
434
  ---
435
435
 
436
- ## 18. Persisting Ephemeral UI State
436
+ ## 19. Persisting Ephemeral UI State
437
437
 
438
438
  ```typescript
439
439
  // BAD — persisting search results, selections, or high-churn data
@@ -451,7 +451,7 @@ Persistence adds I/O overhead on every mutation. For high-churn data, this cause
451
451
 
452
452
  ---
453
453
 
454
- ## 19. Using addCleanup for channel.on()/bus.on() Subscriptions
454
+ ## 20. Using addCleanup for channel.on()/bus.on() Subscriptions
455
455
 
456
456
  ```typescript
457
457
  // BAD — manual addCleanup is easy to forget and not reset-safe
@@ -468,7 +468,7 @@ protected onInit() {
468
468
 
469
469
  ---
470
470
 
471
- ## 20. Missing hydrate() for Async Adapters
471
+ ## 21. Missing hydrate() for Async Adapters
472
472
 
473
473
  ```typescript
474
474
  // BAD — accessing items before hydrate() on async adapter
@@ -5,12 +5,14 @@
5
5
  ```typescript
6
6
  // Core classes and utilities
7
7
  import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
8
+ import { Sorting, Pagination, Selection, Feed } from 'mvc-kit';
8
9
  import { singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
9
10
  import { HttpError, isAbortError, classifyError } from 'mvc-kit';
10
11
  import type { Subscribable, Disposable, Initializable, Listener, Updater, ValidationErrors, TaskState, AppError, AsyncMethodKeys, ResourceAsyncMethodKeys, ChannelStatus } from 'mvc-kit';
11
12
 
12
- // React hooks
13
- import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
13
+ // React hooks and headless components
14
+ import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
15
+ import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
14
16
  import type { StateOf, ItemOf, SingletonClass, ProviderRegistry, ModelHandle, FieldHandle, ProviderProps } from 'mvc-kit/react';
15
17
 
16
18
  // Web storage adapters
@@ -283,6 +285,141 @@ No state, no getters, no async tracking.
283
285
 
284
286
  ---
285
287
 
288
+ ## Composable Helpers
289
+
290
+ Plain classes with `subscribe()` — auto-tracked when declared as ViewModel instance properties. Each has an `apply()` method that transforms arrays. Helpers manage state; ViewModels compose them in getters.
291
+
292
+ ### Sorting\<T\>
293
+
294
+ Multi-column sort state with 3-click toggle cycle (asc → desc → none).
295
+
296
+ ```typescript
297
+ new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] })
298
+ ```
299
+
300
+ - `sorts: readonly SortDescriptor[]` — Current sort descriptors in priority order. Each: `{ key: string, direction: 'asc' | 'desc' }`.
301
+ - `key: string | null` — Primary sort key (first descriptor), or null when empty.
302
+ - `direction: 'asc' | 'desc'` — Primary sort direction. Defaults to `'asc'` when empty.
303
+ - `isSorted(key: string): boolean` — Whether the given key is currently sorted.
304
+ - `directionOf(key: string): 'asc' | 'desc' | null` — Sort direction for a key, or null.
305
+ - `indexOf(key: string): number` — Priority index of a sorted key, or -1.
306
+ - `toggle(key: string): void` — 3-click cycle: not sorted → asc → desc → removed.
307
+ - `setSort(key: string, direction: 'asc' | 'desc'): void` — Replace all with a single sort.
308
+ - `setSorts(sorts: SortDescriptor[]): void` — Replace all sort descriptors.
309
+ - `reset(): void` — Clear all sorts.
310
+ - `apply(items: T[], compareFn?): T[]` — Returns sorted copy. Optional custom comparator `(a, b, key, dir) => number`.
311
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
312
+
313
+ ### Pagination
314
+
315
+ Page/pageSize state with `apply()` slicing.
316
+
317
+ ```typescript
318
+ new Pagination({ pageSize: 25 })
319
+ ```
320
+
321
+ - `page: number` — Current page (1-based).
322
+ - `pageSize: number` — Items per page.
323
+ - `setPage(page: number): void` — Navigate to a specific page (clamped to >= 1).
324
+ - `setPageSize(size: number): void` — Change page size, resets to page 1.
325
+ - `nextPage(): void` — Advance to the next page.
326
+ - `prevPage(): void` — Go back one page. No-op if already on page 1.
327
+ - `hasNext(total: number): boolean` — Whether there is a next page.
328
+ - `hasPrev(): boolean` — Whether there is a previous page.
329
+ - `pageCount(total: number): number` — Compute total pages.
330
+ - `reset(): void` — Reset to page 1.
331
+ - `apply<T>(items: T[]): T[]` — Slice array to the current page window.
332
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
333
+
334
+ ### Selection\<K\>
335
+
336
+ Key-based selection set with toggle/select-all semantics.
337
+
338
+ ```typescript
339
+ new Selection<string>()
340
+ ```
341
+
342
+ - `selected: ReadonlySet<K>` — Current selection.
343
+ - `count: number` — Number of selected items.
344
+ - `hasSelection: boolean` — Whether any items are selected.
345
+ - `isSelected(key: K): boolean` — Check if selected.
346
+ - `toggle(key: K): void` — Add or remove.
347
+ - `select(...keys: K[]): void` — Add to selection.
348
+ - `deselect(...keys: K[]): void` — Remove from selection.
349
+ - `toggleAll(allKeys: K[]): void` — If all selected, deselect all; otherwise select all.
350
+ - `set(...keys: K[]): void` — Replace entire selection atomically. Single notification. No-op if unchanged.
351
+ - `clear(): void` — Deselect all.
352
+ - `selectedFrom<T>(items, keyOf): T[]` — Filter items to those whose key is selected.
353
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
354
+
355
+ ### Feed\<T\>
356
+
357
+ Cursor + hasMore + item accumulation for server-side cursor-based pagination.
358
+
359
+ ```typescript
360
+ new Feed() // untyped (cursor/hasMore only)
361
+ new Feed<Message>() // typed (with item accumulation)
362
+ ```
363
+
364
+ - `cursor: string | null` — Current cursor for next page.
365
+ - `hasMore: boolean` — Whether more pages exist.
366
+ - `items: readonly T[]` — Accumulated items (from appendPage/prependPage).
367
+ - `count: number` — Number of accumulated items.
368
+ - `setResult(result): void` — Update cursor/hasMore only (does not affect items).
369
+ - `appendPage(page: FeedPage<T>): void` — Append page items and update cursor/hasMore.
370
+ - `prependPage(page: FeedPage<T>): void` — Prepend page items and update cursor/hasMore.
371
+ - `push(...items: T[]): void` — Add items without affecting cursor/hasMore.
372
+ - `filter(predicate: (item: T) => boolean): void` — Remove non-matching items. No-op if nothing filtered.
373
+ - `replacePage(page: FeedPage<T>): void` — Replace all items and update cursor/hasMore atomically. Ideal for pull-to-refresh.
374
+ - `reset(): void` — Clear cursor, items, set hasMore to true.
375
+ - `subscribe(listener): () => void` — Auto-tracked by ViewModel.
376
+
377
+ ---
378
+
379
+ ## Headless React Components
380
+
381
+ Unstyled render-prop components from `mvc-kit/react`. They render the output of ViewModel getters and helpers. Neither requires helpers — they work with any array. Helpers don't require components — they work with any rendering approach.
382
+
383
+ ### DataTable\<T\>
384
+
385
+ Unstyled table with sort headers, selection checkboxes, and pagination slots. Accepts helper instances directly via duck-typing.
386
+
387
+ ```tsx
388
+ <DataTable
389
+ items={vm.items}
390
+ columns={columns}
391
+ sort={vm.sorting}
392
+ selection={vm.selection}
393
+ pagination={vm.pagination}
394
+ paginationTotal={vm.filteredCount}
395
+ />
396
+ ```
397
+
398
+ ### CardList\<T\>
399
+
400
+ Unstyled list/grid with render-prop items.
401
+
402
+ ```tsx
403
+ <CardList items={vm.items} renderItem={(item) => <Card {...item} />} />
404
+ ```
405
+
406
+ ### InfiniteScroll
407
+
408
+ IntersectionObserver wrapper that calls `onLoadMore` when the sentinel enters the viewport. Supports `direction="up"` for chat-style reverse scroll.
409
+
410
+ ```tsx
411
+ <InfiniteScroll onLoadMore={() => vm.loadMore()} hasMore={vm.feed.hasMore} loading={vm.async.loadMore.loading}>
412
+ {vm.items.map(item => <Row key={item.id} {...item} />)}
413
+ </InfiniteScroll>
414
+
415
+ // Chat UI (reverse scroll)
416
+ <InfiniteScroll onLoadMore={() => vm.loadOlder()} hasMore={vm.feed.hasMore} direction="up">
417
+ {vm.messages.map(msg => <MessageBubble key={msg.id} message={msg} />)}
418
+ </InfiniteScroll>
419
+ ```
420
+
421
+ ---
422
+
286
423
  ## Singleton Registry
287
424
 
288
425
  ```typescript
@@ -343,6 +480,13 @@ Returns `ModelHandle: { state, errors, valid, dirty, model }`.
343
480
  const { state, errors, valid, dirty, model } = useModel(() => new UserModel({ name: '' }));
344
481
  ```
345
482
 
483
+ ### useModelRef(factory)
484
+ Lifecycle-only (init + dispose), no subscription. Returns `M` directly. Use with `useField` for per-field isolation in large forms.
485
+ ```tsx
486
+ const model = useModelRef(() => new FormModel({ name: '', email: '' }));
487
+ // Pass model to children that use useField — parent never re-renders from model changes.
488
+ ```
489
+
346
490
  ### useField(model, key)
347
491
  Returns `FieldHandle: { value, error, set }`. Surgical re-renders.
348
492
  ```tsx
@@ -229,6 +229,126 @@ Thin subclass for singleton identity. No custom methods — query logic goes in
229
229
 
230
230
  ---
231
231
 
232
+ ## Composable Helpers Pattern
233
+
234
+ Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked — getters that read them recompute when helper state changes.
235
+
236
+ ```typescript
237
+ interface FilterState {
238
+ search: string;
239
+ }
240
+
241
+ class UsersListVM extends ViewModel<FilterState> {
242
+ private users = singleton(UsersResource);
243
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
244
+ readonly pagination = new Pagination({ pageSize: 25 });
245
+ readonly selection = new Selection<string>();
246
+
247
+ // Getters compose helpers via apply()
248
+ get filtered(): User[] {
249
+ const { search } = this.state;
250
+ let result = this.users.items as User[];
251
+ if (search) {
252
+ const q = search.toLowerCase();
253
+ result = result.filter(u => u.name.toLowerCase().includes(q));
254
+ }
255
+ return result;
256
+ }
257
+
258
+ get items(): User[] {
259
+ return this.pagination.apply(this.sorting.apply(this.filtered));
260
+ }
261
+
262
+ get pageCount(): number {
263
+ return this.pagination.pageCount(this.filtered.length);
264
+ }
265
+
266
+ // --- Setters ---
267
+ setSearch(search: string) { this.set({ search }); }
268
+ }
269
+ ```
270
+
271
+ **Key points:**
272
+ - Helpers are plain classes, not ViewModels — no `init()` needed.
273
+ - `apply()` is a pure pipeline — chain them in getters: `pagination.apply(sorting.apply(filtered))`.
274
+ - `Selection` is key-based — works with any ID type.
275
+ - `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
276
+
277
+ ### Feed Pattern (cursor-based)
278
+
279
+ With Resource (shared data cache):
280
+
281
+ ```typescript
282
+ class FeedVM extends ViewModel {
283
+ private resource = singleton(PostsResource);
284
+ readonly feed = new Feed();
285
+
286
+ get items(): Post[] {
287
+ return this.resource.items as Post[];
288
+ }
289
+
290
+ async loadMore() {
291
+ const result = await this.api.getPosts(this.feed.cursor, this.disposeSignal);
292
+ this.resource.upsert(...result.items);
293
+ this.feed.setResult(result);
294
+ }
295
+
296
+ protected onInit() {
297
+ this.loadMore();
298
+ }
299
+ }
300
+ ```
301
+
302
+ With item accumulation (component-scoped):
303
+
304
+ ```typescript
305
+ class MessageThreadVM extends ViewModel<{ draft: string }> {
306
+ readonly feed = new Feed<Message>();
307
+
308
+ get messages() { return this.feed.items; }
309
+
310
+ async loadConversation(id: string) {
311
+ this.feed.reset();
312
+ const page = await this.service.getMessages(id, this.disposeSignal);
313
+ this.feed.appendPage(page);
314
+ }
315
+
316
+ async loadOlder() {
317
+ if (!this.feed.hasMore) return;
318
+ const page = await this.service.getMessages(id, this.disposeSignal, { cursor: this.feed.cursor });
319
+ this.feed.appendPage(page);
320
+ }
321
+ }
322
+ ```
323
+
324
+ ### Headless Components
325
+
326
+ Pass helpers directly — DataTable duck-types their interfaces:
327
+
328
+ ```tsx
329
+ function UsersPage() {
330
+ const [state, vm] = useLocal(UsersListVM, { search: '' });
331
+
332
+ return (
333
+ <div>
334
+ <input value={state.search} onChange={e => vm.setSearch(e.target.value)} />
335
+ <DataTable
336
+ items={vm.items}
337
+ columns={columns}
338
+ sort={vm.sorting}
339
+ selection={vm.selection}
340
+ pagination={vm.pagination}
341
+ paginationTotal={vm.filteredCount}
342
+ />
343
+ </div>
344
+ );
345
+ }
346
+ ```
347
+
348
+ Components and helpers are independent — use helpers without components, or components without helpers. InfiniteScroll supports `direction="up"` for chat-style reverse scroll.
349
+
350
+ ---
351
+
232
352
  ## Persistence Pattern
233
353
 
234
354
  ```typescript
@@ -79,8 +79,10 @@ describe('{{Name}}Model', () => {
79
79
 
80
80
  ## React Usage
81
81
 
82
+ ### Simple form (useModel)
83
+
82
84
  ```tsx
83
- import { useModel, useField } from 'mvc-kit/react';
85
+ import { useModel } from 'mvc-kit/react';
84
86
  import { {{Name}}Model } from '../models/{{Name}}Model';
85
87
 
86
88
  function {{Name}}Form() {
@@ -100,3 +102,38 @@ function {{Name}}Form() {
100
102
  );
101
103
  }
102
104
  ```
105
+
106
+ ### Large form with per-field isolation (useModelRef + useField)
107
+
108
+ ```tsx
109
+ import { useModelRef, useField, useInstance } from 'mvc-kit/react';
110
+ import { {{Name}}Model } from '../models/{{Name}}Model';
111
+
112
+ // Parent — creates model, never re-renders from field changes
113
+ function {{Name}}Form() {
114
+ const model = useModelRef(() => new {{Name}}Model({ name: '' }));
115
+ return (
116
+ <form>
117
+ <NameField model={model} />
118
+ <FormActions model={model} />
119
+ </form>
120
+ );
121
+ }
122
+
123
+ // Per-field child — only re-renders when this field changes
124
+ function NameField({ model }: { model: {{Name}}Model }) {
125
+ const { value, error, set } = useField(model, 'name');
126
+ return (
127
+ <div>
128
+ <input value={value} onChange={e => set(e.target.value)} />
129
+ {error && <span className="error">{error}</span>}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ // Submit button — subscribes to full model for valid/dirty
135
+ function FormActions({ model }: { model: {{Name}}Model }) {
136
+ useInstance(model);
137
+ return <button disabled={!model.valid || !model.dirty}>Save</button>;
138
+ }
139
+ ```
@@ -15,13 +15,27 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
15
15
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
16
16
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
17
17
  | `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
18
+ | `Sorting<T>` | Multi-column sort state with 3-click toggle cycle and `apply()` pipeline | ViewModel property |
19
+ | `Pagination` | Page/pageSize state with `apply()` slicing | ViewModel property |
20
+ | `Selection<K>` | Key-based selection set with toggle/select-all semantics | ViewModel property |
21
+ | `Feed<T>` | Cursor + hasMore + item accumulation for server-side pagination | ViewModel property |
22
+
23
+ ## Headless React Components (`mvc-kit/react`)
24
+
25
+ | Component | Description |
26
+ |-----------|-------------|
27
+ | `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helpers directly |
28
+ | `CardList<T>` | Unstyled list/grid with render-prop items |
29
+ | `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
18
30
 
19
31
  ## Imports
20
32
 
21
33
  ```typescript
22
34
  import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
35
+ import { Sorting, Pagination, Selection, Feed } from 'mvc-kit';
23
36
  import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
24
- import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
37
+ import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
38
+ import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
25
39
  import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
26
40
  import { NativeCollection } from 'mvc-kit/react-native';
27
41
  ```
@@ -110,6 +124,7 @@ function ItemsPage() {
110
124
  | `useSingleton(Class, ...args)` | Singleton, shared state. Returns `[state, instance]`. |
111
125
  | `useInstance(subscribable)` | Subscribe to existing instance. Returns `state`. |
112
126
  | `useModel(factory)` | Model with `{ state, errors, valid, dirty, model }`. |
127
+ | `useModelRef(factory)` | Model lifecycle only (no subscription). Returns `M`. For per-field forms. |
113
128
  | `useField(model, key)` | Single field: `{ value, error, set }`. |
114
129
  | `useEvent(source, event, handler)` | EventBus/ViewModel event subscription. |
115
130
  | `useEmit(bus)` | Stable emit function. |
@@ -159,6 +174,39 @@ class UserModel extends Model<UserFormState> {
159
174
  }
160
175
  ```
161
176
 
177
+ ## Composable Helpers Pattern
178
+
179
+ Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked -- getters that read them recompute when helper state changes.
180
+
181
+ ```typescript
182
+ class UsersListVM extends ViewModel<FilterState> {
183
+ private users = singleton(UsersResource);
184
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
185
+ readonly pagination = new Pagination({ pageSize: 25 });
186
+ readonly selection = new Selection<string>();
187
+
188
+ get filtered(): User[] {
189
+ const { search } = this.state;
190
+ let result = this.users.items as User[];
191
+ if (search) {
192
+ const q = search.toLowerCase();
193
+ result = result.filter(u => u.name.toLowerCase().includes(q));
194
+ }
195
+ return result;
196
+ }
197
+
198
+ get items(): User[] {
199
+ return this.pagination.apply(this.sorting.apply(this.filtered));
200
+ }
201
+ }
202
+ ```
203
+
204
+ **Key points:**
205
+ - Helpers are plain classes, not ViewModels -- no `init()` needed.
206
+ - `apply()` is a pure pipeline -- chain in getters: `pagination.apply(sorting.apply(filtered))`.
207
+ - `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
208
+ - Headless components (`DataTable`, `CardList`, `InfiniteScroll`) are optional -- helpers work with any rendering approach. DataTable accepts helpers directly via duck-typing (`sort={vm.sorting}`, `selection={vm.selection}`, `pagination={vm.pagination}`).
209
+
162
210
  ## Sharing Patterns
163
211
 
164
212
  1. **Pattern A** (default): Parent ViewModel passes props to presentational children
@@ -235,6 +283,8 @@ test('example', () => {
235
283
  - `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
236
284
  - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
237
285
  - Pass-through Service wrapping a typed API client → call the client directly from Resource
286
+ - `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
287
+ - Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
238
288
 
239
289
  ## Decision Framework
240
290
 
@@ -245,6 +295,9 @@ test('example', () => {
245
295
  - Cross-cutting events → **EventBus**
246
296
  - Persistent connection → **Channel**
247
297
  - Coordinates multiple ViewModels → **Controller** (rare)
298
+ - Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
299
+ - Cursor-based server pagination → **Feed** helper
300
+ - Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
248
301
 
249
302
  ## Dev Mode
250
303
 
@@ -15,13 +15,27 @@ This project uses **mvc-kit**, a zero-dependency TypeScript-first reactive state
15
15
  | `EventBus<E>` | Typed pub/sub for cross-cutting events | Singleton |
16
16
  | `Channel<M>` | Persistent connection (WebSocket/SSE) with auto-reconnect | Singleton |
17
17
  | `Controller` | Stateless multi-ViewModel orchestrator (rare) | Component-scoped |
18
+ | `Sorting<T>` | Multi-column sort state with 3-click toggle cycle and `apply()` pipeline | ViewModel property |
19
+ | `Pagination` | Page/pageSize state with `apply()` slicing | ViewModel property |
20
+ | `Selection<K>` | Key-based selection set with toggle/select-all semantics | ViewModel property |
21
+ | `Feed<T>` | Cursor + hasMore + item accumulation for server-side pagination | ViewModel property |
22
+
23
+ ## Headless React Components (`mvc-kit/react`)
24
+
25
+ | Component | Description |
26
+ |-----------|-------------|
27
+ | `DataTable<T>` | Unstyled table with sort headers, selection checkboxes, pagination slots; accepts helpers directly |
28
+ | `CardList<T>` | Unstyled list/grid with render-prop items |
29
+ | `InfiniteScroll` | IntersectionObserver wrapper for infinite loading; `direction="up"` for chat UIs |
18
30
 
19
31
  ## Imports
20
32
 
21
33
  ```typescript
22
34
  import { ViewModel, Model, Collection, PersistentCollection, Resource, Controller, Service, EventBus, Channel } from 'mvc-kit';
35
+ import { Sorting, Pagination, Selection, Feed } from 'mvc-kit';
23
36
  import { singleton, teardownAll, HttpError, isAbortError, classifyError } from 'mvc-kit';
24
- import { useLocal, useSingleton, useInstance, useModel, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
37
+ import { useLocal, useSingleton, useInstance, useModel, useModelRef, useField, useEvent, useEmit, useResolve, useTeardown, Provider } from 'mvc-kit/react';
38
+ import { DataTable, CardList, InfiniteScroll } from 'mvc-kit/react';
25
39
  import { WebStorageCollection, IndexedDBCollection } from 'mvc-kit/web';
26
40
  import { NativeCollection } from 'mvc-kit/react-native';
27
41
  ```
@@ -110,6 +124,7 @@ function ItemsPage() {
110
124
  | `useSingleton(Class, ...args)` | Singleton, shared state. Returns `[state, instance]`. |
111
125
  | `useInstance(subscribable)` | Subscribe to existing instance. Returns `state`. |
112
126
  | `useModel(factory)` | Model with `{ state, errors, valid, dirty, model }`. |
127
+ | `useModelRef(factory)` | Model lifecycle only (no subscription). Returns `M`. For per-field forms. |
113
128
  | `useField(model, key)` | Single field: `{ value, error, set }`. |
114
129
  | `useEvent(source, event, handler)` | EventBus/ViewModel event subscription. |
115
130
  | `useEmit(bus)` | Stable emit function. |
@@ -159,6 +174,39 @@ class UserModel extends Model<UserFormState> {
159
174
  }
160
175
  ```
161
176
 
177
+ ## Composable Helpers Pattern
178
+
179
+ Declare helpers as ViewModel instance properties. They have `subscribe()` so they're auto-tracked — getters that read them recompute when helper state changes.
180
+
181
+ ```typescript
182
+ class UsersListVM extends ViewModel<FilterState> {
183
+ private users = singleton(UsersResource);
184
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
185
+ readonly pagination = new Pagination({ pageSize: 25 });
186
+ readonly selection = new Selection<string>();
187
+
188
+ get filtered(): User[] {
189
+ const { search } = this.state;
190
+ let result = this.users.items as User[];
191
+ if (search) {
192
+ const q = search.toLowerCase();
193
+ result = result.filter(u => u.name.toLowerCase().includes(q));
194
+ }
195
+ return result;
196
+ }
197
+
198
+ get items(): User[] {
199
+ return this.pagination.apply(this.sorting.apply(this.filtered));
200
+ }
201
+ }
202
+ ```
203
+
204
+ **Key points:**
205
+ - Helpers are plain classes, not ViewModels — no `init()` needed.
206
+ - `apply()` is a pure pipeline — chain in getters: `pagination.apply(sorting.apply(filtered))`.
207
+ - `Feed<T>` is for server-side cursor pagination (cursor + hasMore + optional item accumulation), not client-side slicing.
208
+ - Headless components (`DataTable`, `CardList`, `InfiniteScroll`) are optional — helpers work with any rendering approach. DataTable accepts helpers directly via duck-typing (`sort={vm.sorting}`, `selection={vm.selection}`, `pagination={vm.pagination}`).
209
+
162
210
  ## Sharing Patterns
163
211
 
164
212
  1. **Pattern A** (default): Parent ViewModel passes props to presentational children
@@ -235,6 +283,8 @@ test('example', () => {
235
283
  - `reset()` for paginated/incremental loads → use `upsert()` to accumulate data
236
284
  - `useState`/`useMemo`/`useCallback` in connected components → ViewModel handles it
237
285
  - Pass-through Service wrapping a typed API client → call the client directly from Resource
286
+ - `addCleanup` for `channel.on()`/`bus.on()` subscriptions → use `listenTo()` (auto-cleanup on dispose and reset)
287
+ - Missing `hydrate()` for async adapters (IndexedDB, NativeCollection) → call `hydrate()` in `onInit()` before accessing data
238
288
 
239
289
  ## Decision Framework
240
290
 
@@ -245,6 +295,9 @@ test('example', () => {
245
295
  - Cross-cutting events → **EventBus**
246
296
  - Persistent connection → **Channel**
247
297
  - Coordinates multiple ViewModels → **Controller** (rare)
298
+ - Sort/paginate/select on a list → **Sorting/Pagination/Selection** helpers
299
+ - Cursor-based server pagination → **Feed** helper
300
+ - Unstyled table/list/infinite scroll → **DataTable/CardList/InfiniteScroll** components
248
301
 
249
302
  ## Dev Mode
250
303