firstly 0.4.5 → 0.5.1

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.
@@ -1,193 +1,305 @@
1
1
  import { repo as remultRepo, } from 'remult';
2
- import { Log } from '@kitql/helpers';
3
- import { tryCatch, tryCatchSync } from '../core/tryCatch.js';
4
- export class FF_Repo {
5
- ent;
2
+ /**
3
+ * The reactive handle implementation. Not exported directly - consumers use a per-mode
4
+ * alias (`FF_RepoLoad`/`FF_RepoLive`/`FF_RepoPaginate`/`FF_RepoOne`) or the umbrella
5
+ * union `FF_Repo` (any mode). Each verb returns the Omit'd per-mode view of this.
6
+ */
7
+ class FF_RepoHandle {
6
8
  #repo;
9
+ #opts;
10
+ #mode;
11
+ #defaultOrderBy;
7
12
  #paginator;
8
- #findOptions;
9
- #queryOptions;
10
- fields;
11
- metadata;
13
+ #seq = 0;
14
+ items = $state([]);
15
+ /** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
16
+ item = $state(undefined);
12
17
  loading = $state({
13
- init: false,
18
+ init: true,
14
19
  fetching: false,
15
20
  more: false,
16
21
  saving: false,
17
22
  deleting: false,
18
23
  });
19
- items = $state(undefined);
24
+ error = $state(undefined);
25
+ hasNextPage = $state(false);
26
+ /** Aggregations for the whole query (paginate mode). `aggregates.$count` is the total row count. */
20
27
  aggregates = $state(undefined);
21
- hasNextPage = $state(undefined);
22
- item = $state(undefined);
23
- // errors = $state<ErrorInfo<Entity> | undefined>(undefined)
24
- globalError = $state(undefined);
25
- loadingEnd = (toRet) => {
26
- this.loading = {
27
- init: false,
28
- fetching: false,
29
- more: false,
30
- saving: false,
31
- deleting: false,
32
- };
33
- return toRet;
34
- };
35
- constructor(ent, o) {
36
- this.ent = ent;
37
- this.#repo = remultRepo(ent);
38
- this.fields = this.#repo.fields;
39
- this.metadata = this.#repo.metadata;
40
- this.#paginator = undefined;
41
- this.#findOptions = o?.findOptions;
42
- this.#queryOptions = o?.queryOptions;
43
- this.item = o?.item;
44
- if (o?.findOptions !== undefined && !o.findOptions.skipAutoFetch) {
45
- this.loading.init = true;
46
- this.find(o.findOptions);
47
- }
48
- else if (o?.queryOptions !== undefined && !o.queryOptions.skipAutoFetch) {
49
- this.loading.init = true;
50
- this.query(o.queryOptions);
51
- }
52
- else {
53
- this.loadingEnd();
54
- }
28
+ constructor(r, opts, mode) {
29
+ this.#repo = r;
30
+ this.#opts = opts;
31
+ this.#mode = mode;
32
+ this.#defaultOrderBy = r.metadata.options.defaultOrderBy;
33
+ $effect(() => {
34
+ const o = this.#resolve();
35
+ if (o.enabled === false) {
36
+ this.loading.init = false;
37
+ return;
38
+ }
39
+ if (mode === 'live') {
40
+ // Pass orderBy so liveQuery re-sorts incrementally-added rows too;
41
+ // without it a freshly inserted row is appended and `items[0]` (the latest) goes stale.
42
+ const unsub = this.#repo
43
+ .liveQuery({ where: o.where, orderBy: o.orderBy, limit: o.limit, include: o.include })
44
+ .subscribe({
45
+ next: (info) => {
46
+ this.items = info.items;
47
+ this.loading.init = false;
48
+ },
49
+ error: (e) => {
50
+ this.error = e instanceof Error ? e.message : String(e);
51
+ this.loading.init = false;
52
+ },
53
+ });
54
+ return () => unsub();
55
+ }
56
+ // load | paginate | one: (re)fetch; a newer opts() invalidates older responses.
57
+ void this.#load(o, ++this.#seq);
58
+ });
59
+ }
60
+ #resolve() {
61
+ const o = this.#opts();
62
+ return { ...o, orderBy: o.orderBy ?? this.#defaultOrderBy };
55
63
  }
56
- async find(options) {
64
+ async #load(o, seq, keepCount) {
57
65
  this.loading.fetching = true;
58
- const { data, error } = await tryCatch(this.#repo.find({
59
- ...this.#findOptions,
60
- ...options,
61
- }));
62
- if (error) {
63
- this.globalError = error.message;
64
- return this.loadingEnd();
66
+ this.error = undefined;
67
+ try {
68
+ if (this.#mode === 'paginate') {
69
+ // One request returns the page AND the aggregates ($count always, plus any
70
+ // requested): on the client REST proxy remult fetches both together. It sets
71
+ // `hasNextPage` from whether a full page came back (no count probe); `more()`
72
+ // then fetches the next page via a keyset cursor (orderBy + PK).
73
+ const p = await this.#repo
74
+ .query({
75
+ where: o.where,
76
+ orderBy: o.orderBy,
77
+ pageSize: keepCount ?? o.pageSize ?? 25,
78
+ include: o.include,
79
+ aggregate: { ...o.aggregate },
80
+ })
81
+ .paginator();
82
+ if (seq !== this.#seq)
83
+ return;
84
+ this.#paginator = p;
85
+ this.items = p.items;
86
+ this.hasNextPage = p.hasNextPage;
87
+ // `aggregates` is only on the paginator type when the aggregate is non-empty,
88
+ // but remult returns `$count` for the empty case too - so read it through a cast.
89
+ this.aggregates = p.aggregates;
90
+ }
91
+ else if (this.#mode === 'one') {
92
+ const found = await this.#repo.findFirst(o.where, {
93
+ orderBy: o.orderBy,
94
+ include: o.include,
95
+ });
96
+ if (seq !== this.#seq)
97
+ return;
98
+ this.item = found ?? undefined;
99
+ this.items = found ? [found] : [];
100
+ }
101
+ else {
102
+ const items = await this.#repo.find({
103
+ where: o.where,
104
+ orderBy: o.orderBy,
105
+ limit: o.limit,
106
+ include: o.include,
107
+ });
108
+ if (seq !== this.#seq)
109
+ return;
110
+ this.items = items;
111
+ }
65
112
  }
66
- this.items = data;
67
- return this.loadingEnd(data);
68
- }
69
- async query(options) {
70
- this.loading = {
71
- ...this.loading,
72
- fetching: true,
73
- init: this.items === undefined,
74
- };
75
- // REMULT P1: add test for dynamic orderBy in remult
76
- // Looks like only the default orderby of the entity is working
77
- const { data: queryResult, error: queryResultError } = tryCatchSync(() => this.#repo.query({
78
- pageSize: 2,
79
- ...this.#queryOptions,
80
- ...options,
81
- // Yes, we always want to aggregate to get at least the $count!
82
- // And empty object is giving us that
83
- aggregate: {
84
- ...this.#queryOptions?.aggregate,
85
- },
86
- }));
87
- if (queryResultError) {
88
- this.globalError = queryResultError.message;
89
- return this.loadingEnd();
113
+ catch (e) {
114
+ if (seq === this.#seq)
115
+ this.error = e instanceof Error ? e.message : String(e);
90
116
  }
91
- const { data: paginator, error: paginatorError } = await tryCatch(queryResult.paginator());
92
- if (paginatorError) {
93
- this.globalError = paginatorError.message;
94
- return this.loadingEnd();
117
+ finally {
118
+ if (seq === this.#seq) {
119
+ this.loading.init = false;
120
+ this.loading.fetching = false;
121
+ }
95
122
  }
96
- this.#paginator = paginator;
97
- this.items = this.#paginator.items;
98
- // @ts-expect-error - We know the structure will match due to how we define the types
99
- this.aggregates = this.#paginator.aggregates;
100
- this.hasNextPage = this.#paginator.hasNextPage && this.aggregates.$count > this.items.length;
101
- return this.loadingEnd({
102
- items: this.items,
103
- aggregates: this.aggregates,
104
- hasNextPage: this.hasNextPage,
105
- });
106
123
  }
107
- async queryMore() {
108
- if (this.#paginator === undefined) {
109
- new Log('FF_Repo').error('No paginator found');
110
- return undefined;
111
- }
112
- if (this.loading.more) {
113
- // already in progress...
114
- return undefined;
124
+ /** Re-run the current query (load/paginate/one), back to the first page. */
125
+ async refresh() {
126
+ if (this.#mode === 'live')
127
+ throw new Error('FF_Repo: refresh() is not available in live mode');
128
+ await this.#load(this.#resolve(), ++this.#seq);
129
+ }
130
+ /** Load and append the next page (paginate mode). */
131
+ async more() {
132
+ if (this.#mode !== 'paginate')
133
+ throw new Error('FF_Repo: more() requires paginate mode');
134
+ if (!this.#paginator || this.loading.more || !this.hasNextPage)
135
+ return;
136
+ this.loading.more = true;
137
+ try {
138
+ const next = await this.#paginator.nextPage();
139
+ this.#paginator = next;
140
+ this.items = [...this.items, ...next.items];
141
+ this.hasNextPage = next.hasNextPage;
115
142
  }
116
- this.loading = {
117
- ...this.loading,
118
- fetching: true,
119
- more: true,
120
- };
121
- const { data: nextPage, error: nextPageError } = await tryCatch(this.#paginator.nextPage());
122
- if (nextPageError) {
123
- this.globalError = nextPageError.message;
124
- return this.loadingEnd();
143
+ finally {
144
+ this.loading.more = false;
125
145
  }
126
- this.#paginator = nextPage;
127
- this.items?.push(...nextPage.items);
128
- this.hasNextPage = this.#paginator.hasNextPage && this.aggregates.$count > this.items.length;
129
- return this.loadingEnd({
130
- items: this.items,
131
- aggregates: this.aggregates,
132
- hasNextPage: this.hasNextPage,
133
- });
134
146
  }
135
147
  /**
136
- * Refresh query keeping current items count (BIG refresh)
137
- * Useful after edit/delete to stay at current scroll position
148
+ * Run `fn` once - the first time a row exists (`items[0]`).
149
+ *
150
+ * The point: seed editable UI state from the latest row WITHOUT a live query
151
+ * clobbering in-progress edits. It fires a single time, on the first non-empty
152
+ * result, and never again - later ticks (an edit, a delete, a re-sort) are
153
+ * ignored. Empty snapshots are skipped (a liveQuery often emits one before the
154
+ * data lands; there is nothing to seed from an empty result).
155
+ *
156
+ * For pure derived state prefer `$derived`; reach for `onFirst` only when the
157
+ * seed must become independently editable (a draft the user then mutates).
158
+ *
159
+ * ```svelte
160
+ * const list = ffRepo(Plan).listen(() => ({ where: { ownerDid } }))
161
+ * let draft = $state({ title: '' })
162
+ * list.onFirst((latest) => (draft.title = latest.title)) // seed once, then edit freely
163
+ * ```
138
164
  */
139
- async queryRefresh(options) {
140
- const currentCount = this.items?.length ?? this.#queryOptions?.pageSize ?? 25;
141
- this.loading = {
142
- ...this.loading,
143
- fetching: true,
144
- init: this.items === undefined,
145
- };
146
- const { data: queryResult, error: queryResultError } = tryCatchSync(() => this.#repo.query({
147
- ...this.#queryOptions,
148
- ...options,
149
- pageSize: currentCount,
150
- aggregate: {
151
- ...this.#queryOptions?.aggregate,
152
- },
153
- }));
154
- if (queryResultError) {
155
- this.globalError = queryResultError.message;
156
- return this.loadingEnd();
157
- }
158
- const { data: paginator, error: paginatorError } = await tryCatch(queryResult.paginator());
159
- if (paginatorError) {
160
- this.globalError = paginatorError.message;
161
- return this.loadingEnd();
162
- }
163
- this.#paginator = paginator;
164
- this.items = this.#paginator.items;
165
- // @ts-expect-error - We know the structure will match due to how we define the types
166
- this.aggregates = this.#paginator.aggregates;
167
- this.hasNextPage = this.#paginator.hasNextPage && this.aggregates.$count > this.items.length;
168
- return this.loadingEnd({
169
- items: this.items,
170
- aggregates: this.aggregates,
171
- hasNextPage: this.hasNextPage,
165
+ onFirst(fn) {
166
+ let done = false;
167
+ $effect(() => {
168
+ if (done)
169
+ return;
170
+ const latest = this.items[0];
171
+ if (latest == null)
172
+ return;
173
+ fn(latest);
174
+ done = true;
172
175
  });
173
176
  }
177
+ /** Create a new unsaved entity into the `item` slot (for an edit form). */
174
178
  create(...args) {
175
179
  this.item = this.#repo.create(...args);
176
180
  return this.item;
177
181
  }
178
- async delete(...args) {
179
- this.loading.deleting = true;
180
- await this.#repo.delete(...args);
181
- // REMULT P4: return the deleted item ?
182
- if (typeof args[0] === 'string') {
183
- this.items = this.items?.filter((i) => this.metadata.idMetadata.getId(i) !== args[0]);
182
+ // Mutations: run `op`, then the post-write sync on SUCCESS only (a failed write
183
+ // leaves the result untouched). On failure we fill `error` AND re-throw - the
184
+ // caller still gets the rejection (not silenced); `error` is for a reactive
185
+ // UI that wants it. `finally` only flips `loading` (no `await`, which would
186
+ // mask the original error).
187
+ async #write(flag, op, after) {
188
+ this.loading[flag] = true;
189
+ this.error = undefined;
190
+ try {
191
+ const res = await op();
192
+ await after();
193
+ return res;
184
194
  }
185
- else {
186
- this.items = this.items?.filter((i) => this.metadata.idMetadata.getId(i) !== this.metadata.idMetadata.getId(args[0]));
195
+ catch (e) {
196
+ this.error = e instanceof Error ? e.message : String(e);
197
+ throw e;
187
198
  }
188
- if (this.aggregates) {
189
- this.aggregates.$count = this.aggregates.$count - 1;
199
+ finally {
200
+ this.loading[flag] = false;
190
201
  }
191
- return this.loadingEnd();
192
202
  }
203
+ /** Save the current `item` (from `one` / `create()`). To save a specific row, use `.repo.save(row)`. */
204
+ save() {
205
+ return this.#write('saving', () => this.#repo.save(this.#requireItem()), () => this.#resync());
206
+ }
207
+ /** Delete the current `item`. To delete a specific row/id, use `.repo.delete(idOrRow)`. */
208
+ delete() {
209
+ const target = this.#requireItem();
210
+ return this.#write('deleting', () => this.#repo.delete(target), () => {
211
+ // live: liveQuery removes it. one: re-fetch (likely empty now).
212
+ // load/paginate: drop it locally (no refetch).
213
+ if (this.#mode === 'live')
214
+ return;
215
+ if (this.#mode === 'one')
216
+ return this.#resync();
217
+ this.#removeLocal(target);
218
+ });
219
+ }
220
+ /** The current `item` (or throw) - backs the argless `save()`/`delete()`. */
221
+ #requireItem() {
222
+ if (this.item === undefined)
223
+ throw new Error('FF_Repo: no `item` to save/delete - load one first (`one` mode or `create()`), or write a specific row through `.repo`.');
224
+ return this.item;
225
+ }
226
+ // Client-side list reconcilers (no server I/O) - reflect a change you made
227
+ // elsewhere (e.g. via `.repo`) in the reactive `items`. `load`/`paginate` only;
228
+ // `listen` reconciles itself via the liveQuery. `add`/`remove` also adjust
229
+ // `aggregates.$count` (not the other aggregates). For authoritative state, call
230
+ // `refresh()` (it re-pulls and, for paginate, resets to the first page).
231
+ /** Insert into `items` at `top` (default) / `bottom` / an index (`-1` = last). +1 to `$count`. */
232
+ addItem(item, options) {
233
+ const at = options?.at ?? 'top';
234
+ const list = this.items;
235
+ const idx = at === 'top'
236
+ ? 0
237
+ : at === 'bottom'
238
+ ? list.length
239
+ : at < 0
240
+ ? Math.max(0, list.length + at + 1)
241
+ : Math.min(at, list.length);
242
+ this.items = [...list.slice(0, idx), item, ...list.slice(idx)];
243
+ if (this.aggregates)
244
+ this.aggregates.$count += 1;
245
+ }
246
+ /** Replace the row whose id matches `item`'s id (no `$count` change). */
247
+ updateItem(item) {
248
+ const id = this.#repo.metadata.idMetadata.getId(item);
249
+ this.items = this.items.map((x) => (this.#repo.metadata.idMetadata.getId(x) === id ? item : x));
250
+ }
251
+ /** Drop the matching row (pass an id or the item). -1 to `$count`. */
252
+ removeItem(idOrItem) {
253
+ this.#removeLocal(idOrItem);
254
+ }
255
+ #removeLocal(idOrItem) {
256
+ const id = idOrItem != null && typeof idOrItem === 'object'
257
+ ? this.#repo.metadata.idMetadata.getId(idOrItem)
258
+ : idOrItem;
259
+ this.items = this.items.filter((i) => this.#repo.metadata.idMetadata.getId(i) !== id);
260
+ if (this.aggregates)
261
+ this.aggregates.$count = Math.max(0, this.aggregates.$count - 1);
262
+ }
263
+ /** After insert/update (or a `one` delete) in a non-live mode, re-fetch keeping the current count. */
264
+ async #resync() {
265
+ if (this.#mode === 'live')
266
+ return;
267
+ const keepCount = this.#mode === 'paginate' ? this.items.length || undefined : undefined;
268
+ await this.#load(this.#resolve(), ++this.#seq, keepCount);
269
+ }
270
+ /**
271
+ * The entity's remult metadata - the single escape hatch for everything not on
272
+ * this handle: permissions (`apiInsertAllowed()`, `apiUpdateAllowed(item)`,
273
+ * `apiDeleteAllowed(item)`, `apiReadAllowed`), `fields`, `idMetadata`, `options`,
274
+ * `key`. Reflects the current `remult.user`.
275
+ */
276
+ get meta() {
277
+ return this.#repo.metadata;
278
+ }
279
+ /** Escape hatch to the underlying repo (count, findId, upsert, projections, ...). */
280
+ get repo() {
281
+ return this.#repo;
282
+ }
283
+ }
284
+ export function ffRepo(entity) {
285
+ const r = remultRepo(entity);
286
+ const builder = {
287
+ load(o) {
288
+ return new FF_RepoHandle(r, o, 'load');
289
+ },
290
+ listen(o) {
291
+ return new FF_RepoHandle(r, o, 'live');
292
+ },
293
+ paginate(o) {
294
+ return new FF_RepoHandle(r, o, 'paginate');
295
+ },
296
+ one(o) {
297
+ return new FF_RepoHandle(r, o, 'one');
298
+ },
299
+ get meta() {
300
+ return r.metadata;
301
+ },
302
+ repo: r,
303
+ };
304
+ return builder;
193
305
  }
@@ -1,6 +1,10 @@
1
- export { FF_Repo } from './FF_Repo.svelte.js';
1
+ export { ffRepo } from './FF_Repo.svelte.js';
2
+ export type { FF_Repo, FF_RepoOptions, FF_RepoLoading, FF_RepoLoad, FF_RepoLive, FF_RepoPaginate, FF_RepoOne, FF_RepoBuilder, AggregateOptions, QueryOptionsHelper, } from './FF_Repo.svelte.js';
3
+ export { infiniteScroll } from './infiniteScroll.js';
4
+ export type { InfiniteScrollOptions } from './infiniteScroll.js';
2
5
  export { SP } from './class/SP.svelte';
3
6
  export type { ParamDefinition } from './class/SP.svelte';
4
7
  export { initRemultSvelteReactivity } from './initRemultSvelteReactivity';
8
+ export { default as DemoGrid } from './DemoGrid.svelte';
5
9
  export { default as Icon } from './ui/Icon.svelte';
6
10
  export { LibIcon_Empty, LibIcon_Forbidden, LibIcon_ChevronDown, LibIcon_ChevronUp, LibIcon_ChevronLeft, LibIcon_ChevronRight, LibIcon_Search, LibIcon_Check, LibIcon_MultiCheck, LibIcon_Add, LibIcon_MultiAdd, LibIcon_Edit, LibIcon_Eye, LibIcon_EyeOff, LibIcon_Delete, LibIcon_Cross, LibIcon_Save, LibIcon_Man, LibIcon_Woman, LibIcon_Send, LibIcon_Load, LibIcon_Settings, LibIcon_Sort, LibIcon_SortAsc, LibIcon_SortDesc, } from './ui/LibIcon.js';
@@ -1,5 +1,7 @@
1
- export { FF_Repo } from './FF_Repo.svelte.js';
1
+ export { ffRepo } from './FF_Repo.svelte.js';
2
+ export { infiniteScroll } from './infiniteScroll.js';
2
3
  export { SP } from './class/SP.svelte';
3
4
  export { initRemultSvelteReactivity } from './initRemultSvelteReactivity';
5
+ export { default as DemoGrid } from './DemoGrid.svelte';
4
6
  export { default as Icon } from './ui/Icon.svelte';
5
7
  export { LibIcon_Empty, LibIcon_Forbidden, LibIcon_ChevronDown, LibIcon_ChevronUp, LibIcon_ChevronLeft, LibIcon_ChevronRight, LibIcon_Search, LibIcon_Check, LibIcon_MultiCheck, LibIcon_Add, LibIcon_MultiAdd, LibIcon_Edit, LibIcon_Eye, LibIcon_EyeOff, LibIcon_Delete, LibIcon_Cross, LibIcon_Save, LibIcon_Man, LibIcon_Woman, LibIcon_Send, LibIcon_Load, LibIcon_Settings, LibIcon_Sort, LibIcon_SortAsc, LibIcon_SortDesc, } from './ui/LibIcon.js';
@@ -0,0 +1,31 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ export type InfiniteScrollOptions = {
3
+ /** Are there more pages to load? */
4
+ hasMore: () => boolean;
5
+ /** Is a load already in flight? */
6
+ loading: () => boolean;
7
+ /** Load the next page. */
8
+ onMore: () => void;
9
+ /** Preload distance below the scroll viewport, in px (default 1200). */
10
+ rootMarginPx?: number;
11
+ };
12
+ /**
13
+ * Infinite-scroll attachment: put it on a bottom sentinel element and it calls
14
+ * `onMore()` when the sentinel nears the scroll container. The observer is rooted
15
+ * on the nearest scrollable ancestor, so it works when the app scrolls inside an
16
+ * inner div (not the window), and a `rootMarginPx` margin streams the next page in
17
+ * just before the sentinel shows. Guarded by `hasMore()`/`loading()`. Because it
18
+ * observes geometry it never misfires on mount: a full first page leaves the
19
+ * sentinel far below the root.
20
+ *
21
+ * Pairs with `ffRepo(E).paginate(...)`:
22
+ *
23
+ * ```svelte
24
+ * <div {@attach infiniteScroll({
25
+ * hasMore: () => r.hasNextPage,
26
+ * loading: () => r.loading.more,
27
+ * onMore: () => r.more(),
28
+ * })}></div>
29
+ * ```
30
+ */
31
+ export declare function infiniteScroll(opts: InfiniteScrollOptions): Attachment;
@@ -0,0 +1,40 @@
1
+ /** Nearest scrollable ancestor, or null (the document/window scrolls). */
2
+ function scrollParent(el) {
3
+ let p = el.parentElement;
4
+ while (p && p !== document.body) {
5
+ const oy = getComputedStyle(p).overflowY;
6
+ if (oy === 'auto' || oy === 'scroll')
7
+ return p;
8
+ p = p.parentElement;
9
+ }
10
+ return null;
11
+ }
12
+ /**
13
+ * Infinite-scroll attachment: put it on a bottom sentinel element and it calls
14
+ * `onMore()` when the sentinel nears the scroll container. The observer is rooted
15
+ * on the nearest scrollable ancestor, so it works when the app scrolls inside an
16
+ * inner div (not the window), and a `rootMarginPx` margin streams the next page in
17
+ * just before the sentinel shows. Guarded by `hasMore()`/`loading()`. Because it
18
+ * observes geometry it never misfires on mount: a full first page leaves the
19
+ * sentinel far below the root.
20
+ *
21
+ * Pairs with `ffRepo(E).paginate(...)`:
22
+ *
23
+ * ```svelte
24
+ * <div {@attach infiniteScroll({
25
+ * hasMore: () => r.hasNextPage,
26
+ * loading: () => r.loading.more,
27
+ * onMore: () => r.more(),
28
+ * })}></div>
29
+ * ```
30
+ */
31
+ export function infiniteScroll(opts) {
32
+ return (node) => {
33
+ const io = new IntersectionObserver((entries) => {
34
+ if (entries[0]?.isIntersecting && opts.hasMore() && !opts.loading())
35
+ opts.onMore();
36
+ }, { root: scrollParent(node), rootMargin: `${opts.rootMarginPx ?? 1200}px 0px` });
37
+ io.observe(node);
38
+ return () => io.disconnect();
39
+ };
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firstly",
3
- "version": "0.4.5",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Firstly, an opinionated Remult setup!",
6
6
  "funding": "https://github.com/sponsors/jycouet",
@@ -40,8 +40,8 @@
40
40
  "nodemailer": "8.0.5",
41
41
  "tailwind-merge": "3.5.0",
42
42
  "tailwindcss": "4.2.2",
43
- "vite-plugin-kit-routes": "1.0.5",
44
- "vite-plugin-stripper": "0.10.3"
43
+ "vite-plugin-kit-routes": "1.0.6",
44
+ "vite-plugin-stripper": "0.10.4"
45
45
  },
46
46
  "sideEffects": false,
47
47
  "exports": {