@kontsedal/olas-core 0.0.1-rc.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 +64 -0
- package/dist/index.cjs +363 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +178 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +178 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +339 -0
- package/dist/index.mjs.map +1 -0
- package/dist/root-BImHnGj1.mjs +3270 -0
- package/dist/root-BImHnGj1.mjs.map +1 -0
- package/dist/root-Bazp5_Ik.cjs +3347 -0
- package/dist/root-Bazp5_Ik.cjs.map +1 -0
- package/dist/testing.cjs +81 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +56 -0
- package/dist/testing.d.cts.map +1 -0
- package/dist/testing.d.mts +56 -0
- package/dist/testing.d.mts.map +1 -0
- package/dist/testing.mjs +78 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-CAMgqCMz.d.mts +816 -0
- package/dist/types-CAMgqCMz.d.mts.map +1 -0
- package/dist/types-emq_lZd7.d.cts +816 -0
- package/dist/types-emq_lZd7.d.cts.map +1 -0
- package/package.json +47 -0
- package/src/__dev__.d.ts +8 -0
- package/src/controller/define.ts +50 -0
- package/src/controller/index.ts +12 -0
- package/src/controller/instance.ts +499 -0
- package/src/controller/root.ts +160 -0
- package/src/controller/types.ts +195 -0
- package/src/devtools.ts +0 -0
- package/src/emitter.ts +79 -0
- package/src/errors.ts +49 -0
- package/src/forms/field.ts +303 -0
- package/src/forms/form-types.ts +130 -0
- package/src/forms/form.ts +640 -0
- package/src/forms/index.ts +2 -0
- package/src/forms/types.ts +1 -0
- package/src/forms/validators.ts +70 -0
- package/src/index.ts +89 -0
- package/src/query/client.ts +934 -0
- package/src/query/define.ts +154 -0
- package/src/query/entry.ts +322 -0
- package/src/query/focus-online.ts +73 -0
- package/src/query/index.ts +3 -0
- package/src/query/infinite.ts +462 -0
- package/src/query/keys.ts +33 -0
- package/src/query/local.ts +113 -0
- package/src/query/mutation.ts +384 -0
- package/src/query/plugin.ts +135 -0
- package/src/query/types.ts +168 -0
- package/src/query/use.ts +321 -0
- package/src/scope.ts +42 -0
- package/src/selection.ts +146 -0
- package/src/signals/index.ts +3 -0
- package/src/signals/readonly.ts +22 -0
- package/src/signals/runtime.ts +115 -0
- package/src/signals/types.ts +31 -0
- package/src/testing.ts +142 -0
- package/src/timing/debounced.ts +32 -0
- package/src/timing/index.ts +2 -0
- package/src/timing/throttled.ts +46 -0
- package/src/utils.ts +13 -0
package/src/query/use.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { computed, effect, type Signal, signal, untracked } from '../signals'
|
|
2
|
+
import type { ReadSignal } from '../signals/types'
|
|
3
|
+
import type { ClientEntry, InfiniteClientEntry, QueryClient } from './client'
|
|
4
|
+
import type { InfiniteQuery, InfiniteQuerySpec, InfiniteQuerySubscription } from './infinite'
|
|
5
|
+
import type { AsyncStatus, Query, QuerySpec, QuerySubscription, UseOptions } from './types'
|
|
6
|
+
|
|
7
|
+
type QueryInternal<Args extends unknown[], T> = Query<Args, T> & {
|
|
8
|
+
readonly __spec: QuerySpec<Args, T>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class SubscriptionImpl<T> implements QuerySubscription<T> {
|
|
12
|
+
private readonly current$: Signal<ClientEntry<T> | null> = signal(null)
|
|
13
|
+
private readonly previousData$: Signal<T | undefined> = signal(undefined)
|
|
14
|
+
|
|
15
|
+
readonly data: ReadSignal<T | undefined>
|
|
16
|
+
readonly error: ReadSignal<unknown | undefined>
|
|
17
|
+
readonly status: ReadSignal<AsyncStatus>
|
|
18
|
+
readonly isLoading: ReadSignal<boolean>
|
|
19
|
+
readonly isFetching: ReadSignal<boolean>
|
|
20
|
+
readonly isStale: ReadSignal<boolean>
|
|
21
|
+
readonly lastUpdatedAt: ReadSignal<number | undefined>
|
|
22
|
+
readonly hasPendingMutations: ReadSignal<boolean>
|
|
23
|
+
|
|
24
|
+
constructor(private readonly keepPreviousData: boolean) {
|
|
25
|
+
this.data = computed(() => {
|
|
26
|
+
const cur = this.current$.value
|
|
27
|
+
const curData = cur?.entry.data.value
|
|
28
|
+
if (curData !== undefined) return curData
|
|
29
|
+
if (keepPreviousData) return this.previousData$.value
|
|
30
|
+
return undefined
|
|
31
|
+
})
|
|
32
|
+
this.error = computed(() => this.current$.value?.entry.error.value)
|
|
33
|
+
this.status = computed<AsyncStatus>(() => this.current$.value?.entry.status.value ?? 'idle')
|
|
34
|
+
this.isLoading = computed(() => {
|
|
35
|
+
const cur = this.current$.value
|
|
36
|
+
if (!cur) return false
|
|
37
|
+
if (keepPreviousData && this.previousData$.value !== undefined) return false
|
|
38
|
+
return cur.entry.isLoading.value
|
|
39
|
+
})
|
|
40
|
+
this.isFetching = computed(() => this.current$.value?.entry.isFetching.value ?? false)
|
|
41
|
+
this.isStale = computed(() => this.current$.value?.entry.isStale.value ?? true)
|
|
42
|
+
this.lastUpdatedAt = computed(() => this.current$.value?.entry.lastUpdatedAt.value)
|
|
43
|
+
this.hasPendingMutations = computed(
|
|
44
|
+
() => this.current$.value?.entry.hasPendingMutations.value ?? false,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
attach(entry: ClientEntry<T>): void {
|
|
49
|
+
const prev = this.current$.peek()
|
|
50
|
+
if (prev === entry) return
|
|
51
|
+
if (prev && this.keepPreviousData) {
|
|
52
|
+
const prevData = prev.entry.data.peek()
|
|
53
|
+
if (prevData !== undefined) this.previousData$.set(prevData)
|
|
54
|
+
}
|
|
55
|
+
this.current$.set(entry)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
detach(): void {
|
|
59
|
+
this.current$.set(null)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
refetch = (): Promise<T> => {
|
|
63
|
+
const cur = this.current$.peek()
|
|
64
|
+
if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
|
|
65
|
+
return cur.entry.refetch()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
reset = (): void => {
|
|
69
|
+
this.current$.peek()?.entry.reset()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
firstValue = (): Promise<T> => {
|
|
73
|
+
const cur = this.current$.peek()
|
|
74
|
+
if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
|
|
75
|
+
return cur.entry.firstValue()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a subscription + the effect that keeps it bound to the right entry.
|
|
81
|
+
* The controller container wires the disposer into the lifecycle.
|
|
82
|
+
*/
|
|
83
|
+
export function createUse<Args extends unknown[], T>(
|
|
84
|
+
client: QueryClient,
|
|
85
|
+
query: Query<Args, T>,
|
|
86
|
+
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
87
|
+
): { subscription: QuerySubscription<T>; dispose: () => void } {
|
|
88
|
+
const internal = query as unknown as QueryInternal<Args, T>
|
|
89
|
+
const spec = internal.__spec
|
|
90
|
+
const keepPreviousData = spec.keepPreviousData ?? false
|
|
91
|
+
|
|
92
|
+
const keyFn = typeof keyOrOptions === 'function' ? keyOrOptions : keyOrOptions?.key
|
|
93
|
+
const enabledFn =
|
|
94
|
+
typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.enabled : undefined
|
|
95
|
+
|
|
96
|
+
const sub = new SubscriptionImpl<T>(keepPreviousData)
|
|
97
|
+
let currentEntry: ClientEntry<T> | null = null
|
|
98
|
+
|
|
99
|
+
const effectDispose = effect(() => {
|
|
100
|
+
const isEnabled = enabledFn ? enabledFn() : true
|
|
101
|
+
if (!isEnabled) {
|
|
102
|
+
untracked(() => {
|
|
103
|
+
if (currentEntry) {
|
|
104
|
+
currentEntry.release()
|
|
105
|
+
currentEntry = null
|
|
106
|
+
}
|
|
107
|
+
sub.detach()
|
|
108
|
+
})
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
|
|
113
|
+
|
|
114
|
+
untracked(() => {
|
|
115
|
+
const entry = client.bindEntry<Args, T>(query, args)
|
|
116
|
+
if (currentEntry === entry) return
|
|
117
|
+
if (currentEntry) currentEntry.release()
|
|
118
|
+
entry.acquire()
|
|
119
|
+
currentEntry = entry
|
|
120
|
+
sub.attach(entry)
|
|
121
|
+
|
|
122
|
+
const status = entry.entry.status.peek()
|
|
123
|
+
const fetching = entry.entry.isFetching.peek()
|
|
124
|
+
if (!fetching && (status === 'idle' || entry.entry.isStaleNow() || status === 'error')) {
|
|
125
|
+
entry.entry.startFetch().catch(() => {
|
|
126
|
+
/* error captured on entry */
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const dispose = () => {
|
|
133
|
+
effectDispose()
|
|
134
|
+
if (currentEntry) {
|
|
135
|
+
currentEntry.release()
|
|
136
|
+
currentEntry = null
|
|
137
|
+
}
|
|
138
|
+
sub.detach()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { subscription: sub, dispose }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type InfiniteQueryInternal<Args extends unknown[], TPage, TItem> = InfiniteQuery<
|
|
145
|
+
Args,
|
|
146
|
+
TPage,
|
|
147
|
+
TItem
|
|
148
|
+
> & {
|
|
149
|
+
readonly __spec: InfiniteQuerySpec<Args, any, TPage, TItem>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class InfiniteSubscriptionImpl<TPage, TItem> implements InfiniteQuerySubscription<TPage, TItem> {
|
|
153
|
+
private readonly current$: Signal<InfiniteClientEntry<TPage, TItem, unknown> | null> =
|
|
154
|
+
signal(null)
|
|
155
|
+
private readonly previousPages$: Signal<TPage[] | undefined> = signal(undefined)
|
|
156
|
+
|
|
157
|
+
readonly data: ReadSignal<TPage[] | undefined>
|
|
158
|
+
readonly pages: ReadSignal<TPage[]>
|
|
159
|
+
readonly flat: ReadSignal<TItem[]>
|
|
160
|
+
readonly error: ReadSignal<unknown | undefined>
|
|
161
|
+
readonly status: ReadSignal<AsyncStatus>
|
|
162
|
+
readonly isLoading: ReadSignal<boolean>
|
|
163
|
+
readonly isFetching: ReadSignal<boolean>
|
|
164
|
+
readonly isStale: ReadSignal<boolean>
|
|
165
|
+
readonly lastUpdatedAt: ReadSignal<number | undefined>
|
|
166
|
+
readonly hasPendingMutations: ReadSignal<boolean>
|
|
167
|
+
readonly hasNextPage: ReadSignal<boolean>
|
|
168
|
+
readonly hasPreviousPage: ReadSignal<boolean>
|
|
169
|
+
readonly isFetchingNextPage: ReadSignal<boolean>
|
|
170
|
+
readonly isFetchingPreviousPage: ReadSignal<boolean>
|
|
171
|
+
|
|
172
|
+
constructor(private readonly keepPreviousData: boolean) {
|
|
173
|
+
this.pages = computed(() => {
|
|
174
|
+
const cur = this.current$.value
|
|
175
|
+
const ps = cur?.entry.pages.value
|
|
176
|
+
if (ps && ps.length > 0) return ps
|
|
177
|
+
if (keepPreviousData) return this.previousPages$.value ?? []
|
|
178
|
+
return ps ?? []
|
|
179
|
+
})
|
|
180
|
+
this.data = computed(() => {
|
|
181
|
+
const cur = this.current$.value
|
|
182
|
+
const ps = cur?.entry.pages.value
|
|
183
|
+
if (ps && ps.length > 0) return ps
|
|
184
|
+
if (keepPreviousData) {
|
|
185
|
+
const prev = this.previousPages$.value
|
|
186
|
+
if (prev && prev.length > 0) return prev
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
})
|
|
190
|
+
this.flat = computed(() => this.current$.value?.entry.flat.value ?? [])
|
|
191
|
+
this.error = computed(() => this.current$.value?.entry.error.value)
|
|
192
|
+
this.status = computed<AsyncStatus>(() => this.current$.value?.entry.status.value ?? 'idle')
|
|
193
|
+
this.isLoading = computed(() => {
|
|
194
|
+
const cur = this.current$.value
|
|
195
|
+
if (!cur) return false
|
|
196
|
+
if (keepPreviousData) {
|
|
197
|
+
const prev = this.previousPages$.value
|
|
198
|
+
if (prev && prev.length > 0) return false
|
|
199
|
+
}
|
|
200
|
+
return cur.entry.isLoading.value
|
|
201
|
+
})
|
|
202
|
+
this.isFetching = computed(() => this.current$.value?.entry.isFetching.value ?? false)
|
|
203
|
+
this.isStale = computed(() => this.current$.value?.entry.isStale.value ?? true)
|
|
204
|
+
this.lastUpdatedAt = computed(() => this.current$.value?.entry.lastUpdatedAt.value)
|
|
205
|
+
this.hasPendingMutations = computed(
|
|
206
|
+
() => this.current$.value?.entry.hasPendingMutations.value ?? false,
|
|
207
|
+
)
|
|
208
|
+
this.hasNextPage = computed(() => this.current$.value?.entry.hasNextPage.value ?? false)
|
|
209
|
+
this.hasPreviousPage = computed(() => this.current$.value?.entry.hasPreviousPage.value ?? false)
|
|
210
|
+
this.isFetchingNextPage = computed(
|
|
211
|
+
() => this.current$.value?.entry.isFetchingNextPage.value ?? false,
|
|
212
|
+
)
|
|
213
|
+
this.isFetchingPreviousPage = computed(
|
|
214
|
+
() => this.current$.value?.entry.isFetchingPreviousPage.value ?? false,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
attach(entry: InfiniteClientEntry<TPage, TItem, unknown>): void {
|
|
219
|
+
const prev = this.current$.peek()
|
|
220
|
+
if (prev === entry) return
|
|
221
|
+
if (prev && this.keepPreviousData) {
|
|
222
|
+
const prevPages = prev.entry.pages.peek()
|
|
223
|
+
if (prevPages.length > 0) this.previousPages$.set(prevPages)
|
|
224
|
+
}
|
|
225
|
+
this.current$.set(entry)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
detach(): void {
|
|
229
|
+
this.current$.set(null)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
refetch = (): Promise<TPage[]> => {
|
|
233
|
+
const cur = this.current$.peek()
|
|
234
|
+
if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
|
|
235
|
+
return cur.entry.refetch().then(() => cur.entry.pages.peek())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
reset = (): void => {
|
|
239
|
+
this.current$.peek()?.entry.reset()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
firstValue = (): Promise<TPage[]> => {
|
|
243
|
+
const cur = this.current$.peek()
|
|
244
|
+
if (!cur) return Promise.reject(new Error('[olas] no active subscription'))
|
|
245
|
+
return cur.entry.firstValue()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
fetchNextPage = (): Promise<void> => {
|
|
249
|
+
const cur = this.current$.peek()
|
|
250
|
+
if (!cur) return Promise.resolve()
|
|
251
|
+
return cur.entry.fetchNextPage()
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fetchPreviousPage = (): Promise<void> => {
|
|
255
|
+
const cur = this.current$.peek()
|
|
256
|
+
if (!cur) return Promise.resolve()
|
|
257
|
+
return cur.entry.fetchPreviousPage()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function createInfiniteUse<Args extends unknown[], TPage, TItem>(
|
|
262
|
+
client: QueryClient,
|
|
263
|
+
query: InfiniteQuery<Args, TPage, TItem>,
|
|
264
|
+
keyOrOptions?: (() => Args) | UseOptions<Args>,
|
|
265
|
+
): {
|
|
266
|
+
subscription: InfiniteQuerySubscription<TPage, TItem>
|
|
267
|
+
dispose: () => void
|
|
268
|
+
} {
|
|
269
|
+
const spec = (query as unknown as InfiniteQueryInternal<Args, TPage, TItem>).__spec
|
|
270
|
+
const keepPreviousData = spec.keepPreviousData ?? false
|
|
271
|
+
const keyFn = typeof keyOrOptions === 'function' ? keyOrOptions : keyOrOptions?.key
|
|
272
|
+
const enabledFn =
|
|
273
|
+
typeof keyOrOptions === 'object' && keyOrOptions !== null ? keyOrOptions.enabled : undefined
|
|
274
|
+
|
|
275
|
+
const sub = new InfiniteSubscriptionImpl<TPage, TItem>(keepPreviousData)
|
|
276
|
+
let currentEntry: InfiniteClientEntry<TPage, TItem, unknown> | null = null
|
|
277
|
+
|
|
278
|
+
const effectDispose = effect(() => {
|
|
279
|
+
const isEnabled = enabledFn ? enabledFn() : true
|
|
280
|
+
if (!isEnabled) {
|
|
281
|
+
untracked(() => {
|
|
282
|
+
if (currentEntry) {
|
|
283
|
+
currentEntry.release()
|
|
284
|
+
currentEntry = null
|
|
285
|
+
}
|
|
286
|
+
sub.detach()
|
|
287
|
+
})
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const args = (keyFn ? keyFn() : ([] as unknown as Args)) as Args
|
|
292
|
+
|
|
293
|
+
untracked(() => {
|
|
294
|
+
const entry = client.bindInfiniteEntry<Args, TPage, TItem>(query, args)
|
|
295
|
+
if (currentEntry === entry) return
|
|
296
|
+
if (currentEntry) currentEntry.release()
|
|
297
|
+
entry.acquire()
|
|
298
|
+
currentEntry = entry
|
|
299
|
+
sub.attach(entry)
|
|
300
|
+
|
|
301
|
+
const status = entry.entry.status.peek()
|
|
302
|
+
const fetching = entry.entry.isFetching.peek()
|
|
303
|
+
if (!fetching && (status === 'idle' || entry.entry.isStaleNow() || status === 'error')) {
|
|
304
|
+
entry.entry.startFetch().catch(() => {
|
|
305
|
+
/* error captured on entry */
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const dispose = () => {
|
|
312
|
+
effectDispose()
|
|
313
|
+
if (currentEntry) {
|
|
314
|
+
currentEntry.release()
|
|
315
|
+
currentEntry = null
|
|
316
|
+
}
|
|
317
|
+
sub.detach()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { subscription: sub, dispose }
|
|
321
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed cross-tree data slot. Provided by an ancestor via `ctx.provide(scope, value)`
|
|
3
|
+
* and consumed anywhere in its subtree via `ctx.inject(scope)`. Defined at module
|
|
4
|
+
* scope so the identity is stable across calls. See spec §10.3.
|
|
5
|
+
*/
|
|
6
|
+
export type Scope<T> = {
|
|
7
|
+
readonly __olas: 'scope'
|
|
8
|
+
/** Per-scope identity; matches across `provide` / `inject`. */
|
|
9
|
+
readonly __id: symbol
|
|
10
|
+
/** Optional human-readable name (used in error messages). */
|
|
11
|
+
readonly name?: string
|
|
12
|
+
/** Default value used when no provider exists; `undefined` if none was set. */
|
|
13
|
+
readonly default?: T
|
|
14
|
+
/** True iff `defineScope` was called with a `default` (even `default: undefined`). */
|
|
15
|
+
readonly hasDefault: boolean
|
|
16
|
+
// Phantom for inference — typed `T` is preserved through the scope's lifetime.
|
|
17
|
+
readonly __t?: T
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ScopeOptions<T> = {
|
|
21
|
+
default?: T
|
|
22
|
+
name?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a scope. The returned value is the typed handle passed to
|
|
27
|
+
* `ctx.provide(scope, value)` and `ctx.inject(scope)`. Identity is keyed by
|
|
28
|
+
* an internal symbol so two `defineScope()` calls — even with identical
|
|
29
|
+
* options — yield distinct scopes.
|
|
30
|
+
*/
|
|
31
|
+
export function defineScope<T>(options?: ScopeOptions<T>): Scope<T> {
|
|
32
|
+
const hasDefault = options !== undefined && 'default' in options
|
|
33
|
+
const name = options?.name
|
|
34
|
+
const scope: Scope<T> = {
|
|
35
|
+
__olas: 'scope',
|
|
36
|
+
__id: Symbol(name ?? 'scope'),
|
|
37
|
+
hasDefault,
|
|
38
|
+
...(name !== undefined ? { name } : {}),
|
|
39
|
+
...(hasDefault ? { default: options?.default as T } : {}),
|
|
40
|
+
}
|
|
41
|
+
return scope
|
|
42
|
+
}
|
package/src/selection.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { computed, signal } from './signals'
|
|
2
|
+
import { readOnly } from './signals/readonly'
|
|
3
|
+
import type { ReadSignal } from './signals/types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Multi-select state for tables / lists with bulk actions (spec §17.5).
|
|
7
|
+
*
|
|
8
|
+
* Plain function — not bound to `ctx`. Place it in a controller's closure so
|
|
9
|
+
* it dies with the closure. The phantom `T` parameter brands the selection by
|
|
10
|
+
* item type; IDs are always strings.
|
|
11
|
+
*/
|
|
12
|
+
// biome-ignore lint/correctness/noUnusedVariables: phantom branding param (spec §17.5)
|
|
13
|
+
export type Selection<T = unknown> = {
|
|
14
|
+
selectedIds: ReadSignal<ReadonlySet<string>>
|
|
15
|
+
size: ReadSignal<number>
|
|
16
|
+
isSelected(id: string): ReadSignal<boolean>
|
|
17
|
+
|
|
18
|
+
select(id: string): void
|
|
19
|
+
deselect(id: string): void
|
|
20
|
+
toggle(id: string): void
|
|
21
|
+
clear(): void
|
|
22
|
+
selectAll(ids: readonly string[]): void
|
|
23
|
+
|
|
24
|
+
handleClick(
|
|
25
|
+
id: string,
|
|
26
|
+
mods: { shift?: boolean; meta?: boolean },
|
|
27
|
+
ordered: readonly string[],
|
|
28
|
+
): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a `Selection<T>`. Optional `initial` seeds the selected set.
|
|
33
|
+
*
|
|
34
|
+
* `handleClick` encapsulates the standard click semantics:
|
|
35
|
+
* - plain click → select only `id` (anchor moves to `id`)
|
|
36
|
+
* - meta-click → toggle `id` (anchor moves to `id` on add)
|
|
37
|
+
* - shift-click → range from anchor to `id` along `ordered` (anchor sticks,
|
|
38
|
+
* so subsequent shift-clicks extend from the same origin)
|
|
39
|
+
*
|
|
40
|
+
* Spec §16.5 / §17.5.
|
|
41
|
+
*/
|
|
42
|
+
export function selection<T = unknown>(options?: { initial?: readonly string[] }): Selection<T> {
|
|
43
|
+
const ids = signal<ReadonlySet<string>>(new Set(options?.initial))
|
|
44
|
+
let anchor: string | null = options?.initial?.length
|
|
45
|
+
? (options.initial[options.initial.length - 1] ?? null)
|
|
46
|
+
: null
|
|
47
|
+
// Snapshot of the selection just before the first shift-click of a run.
|
|
48
|
+
// Subsequent shift-clicks re-compute the range against this snapshot so the
|
|
49
|
+
// user can shrink or grow the range. Reset on any non-shift click.
|
|
50
|
+
let preShiftSelection: ReadonlySet<string> | null = null
|
|
51
|
+
|
|
52
|
+
const size = computed(() => ids.value.size)
|
|
53
|
+
|
|
54
|
+
const isSelected = (id: string): ReadSignal<boolean> => computed(() => ids.value.has(id))
|
|
55
|
+
|
|
56
|
+
const select = (id: string): void => {
|
|
57
|
+
const prev = ids.peek()
|
|
58
|
+
if (!prev.has(id)) {
|
|
59
|
+
const next = new Set(prev)
|
|
60
|
+
next.add(id)
|
|
61
|
+
ids.set(next)
|
|
62
|
+
}
|
|
63
|
+
anchor = id
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const deselect = (id: string): void => {
|
|
67
|
+
const prev = ids.peek()
|
|
68
|
+
if (!prev.has(id)) return
|
|
69
|
+
const next = new Set(prev)
|
|
70
|
+
next.delete(id)
|
|
71
|
+
ids.set(next)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toggle = (id: string): void => {
|
|
75
|
+
const prev = ids.peek()
|
|
76
|
+
const next = new Set(prev)
|
|
77
|
+
if (prev.has(id)) {
|
|
78
|
+
next.delete(id)
|
|
79
|
+
} else {
|
|
80
|
+
next.add(id)
|
|
81
|
+
anchor = id
|
|
82
|
+
}
|
|
83
|
+
ids.set(next)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const clear = (): void => {
|
|
87
|
+
if (ids.peek().size === 0) {
|
|
88
|
+
anchor = null
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
ids.set(new Set())
|
|
92
|
+
anchor = null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const selectAll = (incoming: readonly string[]): void => {
|
|
96
|
+
ids.set(new Set(incoming))
|
|
97
|
+
anchor = incoming.length > 0 ? (incoming[incoming.length - 1] ?? null) : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const handleClick = (
|
|
101
|
+
id: string,
|
|
102
|
+
mods: { shift?: boolean; meta?: boolean },
|
|
103
|
+
ordered: readonly string[],
|
|
104
|
+
): void => {
|
|
105
|
+
if (mods.shift && anchor !== null) {
|
|
106
|
+
const anchorIdx = ordered.indexOf(anchor)
|
|
107
|
+
const targetIdx = ordered.indexOf(id)
|
|
108
|
+
if (anchorIdx === -1 || targetIdx === -1) {
|
|
109
|
+
// anchor or target not visible — fall back to plain select
|
|
110
|
+
ids.set(new Set([id]))
|
|
111
|
+
anchor = id
|
|
112
|
+
preShiftSelection = null
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (preShiftSelection === null) {
|
|
116
|
+
preShiftSelection = ids.peek()
|
|
117
|
+
}
|
|
118
|
+
const [lo, hi] = anchorIdx < targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx]
|
|
119
|
+
const next = new Set(preShiftSelection)
|
|
120
|
+
for (const k of ordered.slice(lo, hi + 1)) next.add(k)
|
|
121
|
+
ids.set(next)
|
|
122
|
+
// Anchor stays — subsequent shift-clicks extend from the same origin.
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
// Any non-shift click ends the shift run.
|
|
126
|
+
preShiftSelection = null
|
|
127
|
+
if (mods.meta) {
|
|
128
|
+
toggle(id)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
ids.set(new Set([id]))
|
|
132
|
+
anchor = id
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
selectedIds: readOnly(ids),
|
|
137
|
+
size,
|
|
138
|
+
isSelected,
|
|
139
|
+
select,
|
|
140
|
+
deselect,
|
|
141
|
+
toggle,
|
|
142
|
+
clear,
|
|
143
|
+
selectAll,
|
|
144
|
+
handleClick,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReadSignal } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project a Signal (or any object with a reactive `value` + `peek` + `subscribe`)
|
|
5
|
+
* as a `ReadSignal`. The returned object does not expose `set` / `update` /
|
|
6
|
+
* settable `value`, so it can be returned from APIs without callers mutating it.
|
|
7
|
+
*
|
|
8
|
+
* Internal helper — not exported from the package's public surface.
|
|
9
|
+
*/
|
|
10
|
+
export function readOnly<T>(source: ReadSignal<T>): ReadSignal<T> {
|
|
11
|
+
return {
|
|
12
|
+
get value() {
|
|
13
|
+
return source.value
|
|
14
|
+
},
|
|
15
|
+
peek() {
|
|
16
|
+
return source.peek()
|
|
17
|
+
},
|
|
18
|
+
subscribe(handler) {
|
|
19
|
+
return source.subscribe(handler)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
batch as _batch,
|
|
3
|
+
computed as _computed,
|
|
4
|
+
effect as _effect,
|
|
5
|
+
signal as _signal,
|
|
6
|
+
untracked as _untracked,
|
|
7
|
+
type ReadonlySignal as PreactReadonlySignal,
|
|
8
|
+
type Signal as PreactSignal,
|
|
9
|
+
} from '@preact/signals-core'
|
|
10
|
+
|
|
11
|
+
import type { Computed, Signal } from './types'
|
|
12
|
+
|
|
13
|
+
class SignalImpl<T> implements Signal<T> {
|
|
14
|
+
private readonly inner: PreactSignal<T>
|
|
15
|
+
|
|
16
|
+
constructor(initial: T) {
|
|
17
|
+
this.inner = _signal<T>(initial)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get value(): T {
|
|
21
|
+
return this.inner.value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
set value(next: T) {
|
|
25
|
+
this.inner.value = next
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
peek(): T {
|
|
29
|
+
return this.inner.peek()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
subscribe(handler: (value: T) => void): () => void {
|
|
33
|
+
return this.inner.subscribe(handler)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
set(value: T): void {
|
|
37
|
+
this.inner.value = value
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
update(fn: (prev: T) => T): void {
|
|
41
|
+
this.inner.value = fn(this.inner.peek())
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class ComputedImpl<T> implements Computed<T> {
|
|
46
|
+
private readonly inner: PreactReadonlySignal<T>
|
|
47
|
+
|
|
48
|
+
constructor(fn: () => T) {
|
|
49
|
+
this.inner = _computed<T>(fn)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get value(): T {
|
|
53
|
+
return this.inner.value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
peek(): T {
|
|
57
|
+
return this.inner.peek()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
subscribe(handler: (value: T) => void): () => void {
|
|
61
|
+
return this.inner.subscribe(handler)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a writable `Signal<T>`. Reads track the current auto-tracking scope
|
|
67
|
+
* (effect / computed); writes notify all subscribers (deduped via `Object.is`).
|
|
68
|
+
*
|
|
69
|
+
* Spec §20.1. For a single-pass non-tracked read use `signal.peek()`.
|
|
70
|
+
*/
|
|
71
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
72
|
+
return new SignalImpl(initial)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a `Computed<T>` — a read-only derived signal. The provided `fn` is
|
|
77
|
+
* re-evaluated whenever a signal it read during its last run changes; the
|
|
78
|
+
* resulting value is cached until then.
|
|
79
|
+
*
|
|
80
|
+
* Spec §20.1. The graph is glitch-free: a `computed` re-runs at most once per
|
|
81
|
+
* batched-write cycle.
|
|
82
|
+
*/
|
|
83
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
84
|
+
return new ComputedImpl(fn)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run `fn` immediately and again whenever any signal it reads changes. If
|
|
89
|
+
* `fn` returns a function, that function is called as a cleanup before the
|
|
90
|
+
* next re-run and on dispose.
|
|
91
|
+
*
|
|
92
|
+
* Returns a `dispose` function. Inside a controller use `ctx.effect(...)`
|
|
93
|
+
* instead — that variant is auto-disposed with the controller.
|
|
94
|
+
*/
|
|
95
|
+
export function effect(fn: () => void | (() => void)): () => void {
|
|
96
|
+
return _effect(fn)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Batch synchronous signal writes so subscribers see one notification at the
|
|
101
|
+
* end of the batch rather than one per write. Returns whatever `fn` returns.
|
|
102
|
+
*/
|
|
103
|
+
export function batch<T>(fn: () => T): T {
|
|
104
|
+
return _batch(fn)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run `fn` with auto-tracking suppressed — signals read inside don't become
|
|
109
|
+
* dependencies of the surrounding `computed` / `effect`. Useful for "read
|
|
110
|
+
* these signals once to log them" or for snapshotting state inside an effect
|
|
111
|
+
* without subscribing to it. For a single-signal peek, prefer `signal.peek()`.
|
|
112
|
+
*/
|
|
113
|
+
export function untracked<T>(fn: () => T): T {
|
|
114
|
+
return _untracked(fn)
|
|
115
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only reactive value. Reading `.value` inside a tracking scope
|
|
3
|
+
* (`computed` / `effect`) registers a dependency; `peek()` reads without
|
|
4
|
+
* tracking; `subscribe(handler)` fires `handler` immediately with the current
|
|
5
|
+
* value and on every change until the returned unsubscribe is called.
|
|
6
|
+
*/
|
|
7
|
+
export type ReadSignal<T> = {
|
|
8
|
+
readonly value: T
|
|
9
|
+
/** Read the current value without registering a dependency. */
|
|
10
|
+
peek(): T
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to value changes. The handler is called synchronously with the
|
|
13
|
+
* current value on subscribe and on every change thereafter. Returns the
|
|
14
|
+
* unsubscribe function.
|
|
15
|
+
*/
|
|
16
|
+
subscribe(handler: (value: T) => void): () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Writable reactive value. `value` is assignable; `set(value)` is the
|
|
21
|
+
* functional equivalent; `update(fn)` reads (peek) and writes the result of
|
|
22
|
+
* `fn(previous)`.
|
|
23
|
+
*/
|
|
24
|
+
export type Signal<T> = ReadSignal<T> & {
|
|
25
|
+
value: T
|
|
26
|
+
set(value: T): void
|
|
27
|
+
update(fn: (prev: T) => T): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A read-only derived signal — alias of `ReadSignal<T>`. */
|
|
31
|
+
export type Computed<T> = ReadSignal<T>
|