firstly 0.5.0 → 0.6.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 (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/esm/core/FF_Filter.d.ts +2 -2
  3. package/esm/core/FF_Filter.js +2 -2
  4. package/esm/core/FF_Validators.d.ts +2 -0
  5. package/esm/core/FF_Validators.js +8 -10
  6. package/esm/core/containsWords.d.ts +2 -2
  7. package/esm/core/containsWords.js +2 -2
  8. package/esm/core/tailwind.d.ts +3 -4
  9. package/esm/core/tailwind.js +3 -4
  10. package/esm/svelte/DemoForm.svelte +121 -0
  11. package/esm/svelte/DemoForm.svelte.d.ts +42 -0
  12. package/esm/svelte/DemoGrid.svelte +258 -0
  13. package/esm/svelte/DemoGrid.svelte.d.ts +49 -0
  14. package/esm/svelte/DialogOpenTest.svelte +10 -0
  15. package/esm/svelte/DialogOpenTest.svelte.d.ts +8 -0
  16. package/esm/svelte/FF_Config.svelte +13 -0
  17. package/esm/svelte/FF_Config.svelte.d.ts +3 -0
  18. package/esm/svelte/FF_Config.svelte.js +38 -0
  19. package/esm/svelte/FF_DialogManager.svelte +251 -0
  20. package/esm/svelte/FF_DialogManager.svelte.d.ts +13 -0
  21. package/esm/svelte/FF_PromptDefault.svelte +85 -0
  22. package/esm/svelte/FF_PromptDefault.svelte.d.ts +9 -0
  23. package/esm/svelte/FF_ToastHtml.svelte +9 -0
  24. package/esm/svelte/FF_ToastHtml.svelte.d.ts +6 -0
  25. package/esm/svelte/FF_ToastManager.svelte +22 -0
  26. package/esm/svelte/FF_ToastManager.svelte.d.ts +4 -0
  27. package/esm/svelte/dialog.svelte.d.ts +209 -0
  28. package/esm/svelte/dialog.svelte.js +243 -0
  29. package/esm/svelte/ff.svelte.d.ts +294 -0
  30. package/esm/svelte/ff.svelte.js +599 -0
  31. package/esm/svelte/index.d.ts +14 -2
  32. package/esm/svelte/index.js +9 -1
  33. package/esm/svelte/infiniteScroll.d.ts +1 -1
  34. package/esm/svelte/infiniteScroll.js +1 -1
  35. package/esm/svelte/toast.d.ts +59 -0
  36. package/esm/svelte/toast.js +92 -0
  37. package/esm/virtual/StateDemoEnum.js +1 -1
  38. package/package.json +4 -3
  39. package/esm/svelte/FF_Repo.svelte.d.ts +0 -191
  40. package/esm/svelte/FF_Repo.svelte.js +0 -312
@@ -0,0 +1,243 @@
1
+ export { resolveMessage } from '../core/FF_Validators.js';
2
+ let _dialogs = $state([]);
3
+ let _confirms = $state([]);
4
+ let _prompts = $state([]);
5
+ let _nextId = 1;
6
+ async function tryClose(d, result) {
7
+ if (!result.ok && d.options.allowClose) {
8
+ const allowed = await d.options.allowClose();
9
+ if (!allowed)
10
+ return false;
11
+ }
12
+ d.resolve(result);
13
+ _dialogs = _dialogs.filter((x) => x.id !== d.id);
14
+ return true;
15
+ }
16
+ function normaliseResult(arg) {
17
+ if (arg === undefined)
18
+ return { ok: false };
19
+ if (typeof arg === 'object' && arg !== null && 'ok' in arg) {
20
+ return arg;
21
+ }
22
+ return { ok: true, data: arg };
23
+ }
24
+ export const dialog = {
25
+ get list() {
26
+ return _dialogs;
27
+ },
28
+ get confirmList() {
29
+ return _confirms;
30
+ },
31
+ get promptList() {
32
+ return _prompts;
33
+ },
34
+ /** Open a dialog. Resolves when it closes. `body` is a snippet receiving `close(result?)`. */
35
+ show(body, options = {}) {
36
+ return new Promise((resolve) => {
37
+ _dialogs = [
38
+ ..._dialogs,
39
+ {
40
+ id: _nextId++,
41
+ render: { kind: 'snippet', body: body },
42
+ options: {
43
+ dismissible: options.dismissible ?? true,
44
+ width: options.width ?? 'md',
45
+ allowClose: options.allowClose,
46
+ },
47
+ resolve: resolve,
48
+ },
49
+ ];
50
+ });
51
+ },
52
+ /**
53
+ * Open a dialog from a **component + props** (the natural door for reusable dialogs).
54
+ * `close` is injected as a prop; declare it as `close: DialogClose<T>` and `open` infers
55
+ * the resolved `data` type from it - no call-site generic, no cast. `props` is a snapshot
56
+ * at open time; for reactive bodies use `show(snippet)`.
57
+ */
58
+ open(component, options = {}) {
59
+ return new Promise((resolve) => {
60
+ _dialogs = [
61
+ ..._dialogs,
62
+ {
63
+ id: _nextId++,
64
+ render: {
65
+ kind: 'component',
66
+ component: component,
67
+ props: (options.props ?? {}),
68
+ },
69
+ options: {
70
+ dismissible: options.dismissible ?? true,
71
+ width: options.width ?? 'md',
72
+ allowClose: options.allowClose,
73
+ },
74
+ resolve: resolve,
75
+ },
76
+ ];
77
+ });
78
+ },
79
+ /**
80
+ * Yes/no confirmation, rendered via the manager's `confirm` snippet. Resolves a
81
+ * `DialogResult` - `{ ok: true }` when confirmed, `{ ok: false }` when cancelled/dismissed
82
+ * (no `data`). Same `{ ok }` shape as `show`/`prompt`, so `if ((await dialog.confirm(...)).ok)`.
83
+ * Labels accept a `LocalizedMessage` (a string, or a paraglide/i18next message fn).
84
+ */
85
+ confirm(message, opts = {}) {
86
+ return new Promise((resolve) => {
87
+ _confirms = [
88
+ ..._confirms,
89
+ {
90
+ id: _nextId++,
91
+ message,
92
+ title: opts.title,
93
+ // Labels left undefined fall back to `<FF_Config>` (then the built-in) at render.
94
+ confirmLabel: opts.confirmLabel,
95
+ cancelLabel: opts.cancelLabel,
96
+ danger: opts.danger ?? false,
97
+ resolve,
98
+ },
99
+ ];
100
+ });
101
+ },
102
+ /**
103
+ * Ask for a single text value. Resolves a `DialogResult<string>` - `{ ok: true, data }` with the
104
+ * (trimmed) value, or `{ ok: false }` if cancelled/dismissed. The `{ ok }` flag disambiguates
105
+ * "cancelled" from "submitted an empty string" (both would collapse to a falsy value otherwise).
106
+ * Rendered via the manager's `prompt` snippet, or a built-in default.
107
+ */
108
+ prompt(opts = {}) {
109
+ return new Promise((resolve) => {
110
+ _prompts = [
111
+ ..._prompts,
112
+ {
113
+ id: _nextId++,
114
+ title: opts.title,
115
+ label: opts.label,
116
+ placeholder: opts.placeholder,
117
+ initial: opts.initial ?? '',
118
+ // Labels left undefined fall back to `<FF_Config>` (then the built-in) at render.
119
+ confirmLabel: opts.confirmLabel,
120
+ cancelLabel: opts.cancelLabel,
121
+ hint: opts.hint,
122
+ resolve,
123
+ },
124
+ ];
125
+ });
126
+ },
127
+ /** Internal (manager): resolve a prompt with a value (or null to cancel). */
128
+ _resolvePrompt(id, value) {
129
+ const p = _prompts.find((x) => x.id === id);
130
+ if (!p)
131
+ return;
132
+ p.resolve(value === null ? { ok: false } : { ok: true, data: value });
133
+ _prompts = _prompts.filter((x) => x.id !== id);
134
+ },
135
+ /** Dismiss the topmost prompt (resolves `{ ok: false }`). */
136
+ dismissTopPrompt() {
137
+ const p = _prompts.at(-1);
138
+ if (!p)
139
+ return;
140
+ p.resolve({ ok: false });
141
+ _prompts = _prompts.filter((x) => x.id !== p.id);
142
+ },
143
+ /** Internal (manager): resolve a dialog with an explicit result. */
144
+ async _close(id, result) {
145
+ const d = _dialogs.find((x) => x.id === id);
146
+ if (!d)
147
+ return;
148
+ await tryClose(d, normaliseResult(result));
149
+ },
150
+ /** Internal (manager): dismiss a specific dialog (Esc / backdrop / close button) - honours `dismissible` + `allowClose`. */
151
+ async requestClose(id) {
152
+ const d = _dialogs.find((x) => x.id === id);
153
+ if (!d || !d.options.dismissible)
154
+ return;
155
+ await tryClose(d, { ok: false });
156
+ },
157
+ /** Internal (manager): resolve a confirm. */
158
+ _resolveConfirm(id, yes) {
159
+ const c = _confirms.find((x) => x.id === id);
160
+ if (!c)
161
+ return;
162
+ c.resolve(yes ? { ok: true, data: undefined } : { ok: false });
163
+ _confirms = _confirms.filter((x) => x.id !== id);
164
+ },
165
+ /** Dismiss the topmost dialog (honours `dismissible`). */
166
+ async dismissTop() {
167
+ const d = _dialogs.at(-1);
168
+ if (!d || !d.options.dismissible)
169
+ return;
170
+ await tryClose(d, { ok: false });
171
+ },
172
+ /** Dismiss the topmost confirm (resolves `{ ok: false }`). */
173
+ dismissTopConfirm() {
174
+ const c = _confirms.at(-1);
175
+ if (!c)
176
+ return;
177
+ c.resolve({ ok: false });
178
+ _confirms = _confirms.filter((x) => x.id !== c.id);
179
+ },
180
+ /** Force-close everything (e.g. on full-page navigation). All resolve `{ ok: false }`. */
181
+ closeAll() {
182
+ for (const d of _dialogs)
183
+ d.resolve({ ok: false });
184
+ for (const c of _confirms)
185
+ c.resolve({ ok: false });
186
+ for (const p of _prompts)
187
+ p.resolve({ ok: false });
188
+ _dialogs = [];
189
+ _confirms = [];
190
+ _prompts = [];
191
+ },
192
+ };
193
+ /**
194
+ * `use:ffAutofocus` - focus the first focusable element inside the node (after mount).
195
+ * Put it on your dialog panel so keyboard users land inside it.
196
+ */
197
+ export function ffAutofocus(node) {
198
+ queueMicrotask(() => {
199
+ node
200
+ .querySelector('input, textarea, select, button, [tabindex]:not([tabindex="-1"])')
201
+ ?.focus();
202
+ });
203
+ }
204
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
205
+ /**
206
+ * `use:ffTrapFocus` - keep Tab / Shift+Tab cycling within `node` (a dialog panel) instead
207
+ * of escaping into the background. Put it on your panel alongside `ffAutofocus`. SSR-safe
208
+ * (the listener is only attached in the browser, where actions run).
209
+ */
210
+ export function ffTrapFocus(node) {
211
+ function onKeydown(e) {
212
+ if (e.key !== 'Tab')
213
+ return;
214
+ const focusable = Array.from(node.querySelectorAll(FOCUSABLE)).filter((el) => el.offsetParent !== null || el === document.activeElement);
215
+ if (focusable.length === 0) {
216
+ // Nothing focusable inside: keep focus on the panel itself.
217
+ e.preventDefault();
218
+ node.focus();
219
+ return;
220
+ }
221
+ const first = focusable[0];
222
+ const last = focusable[focusable.length - 1];
223
+ const active = document.activeElement;
224
+ if (e.shiftKey) {
225
+ if (active === first || !node.contains(active)) {
226
+ e.preventDefault();
227
+ last.focus();
228
+ }
229
+ }
230
+ else {
231
+ if (active === last || !node.contains(active)) {
232
+ e.preventDefault();
233
+ first.focus();
234
+ }
235
+ }
236
+ }
237
+ node.addEventListener('keydown', onKeydown);
238
+ return {
239
+ destroy() {
240
+ node.removeEventListener('keydown', onKeydown);
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,294 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type ClassType, type EntityFilter, type EntityMetadata, type EntityOrderBy, type GroupByOptions, type GroupByResult, type MembersOnly, type MembersToInclude, type NumericKeys, type QueryOptions, type Repository } from 'remult';
3
+ import type { LocalizedMessage } from '../core/FF_Validators.js';
4
+ import { type DialogClose, type DialogOptions, type DialogResult } from './dialog.svelte.js';
5
+ /**
6
+ * `ff` - the firstly reactive layer over a Remult entity, as Svelte runes. Two shapes:
7
+ *
8
+ * ff(E).many(() => ({ where }), strategy?) // list + editing draft + writes
9
+ * ff(E).one(() => ({ where })) // a single record bound to `item`
10
+ *
11
+ * `many`'s `strategy` is the fetch mode: 'paginate' (page + $count + more(), default), 'listen'
12
+ * (liveQuery, auto-updates), or 'load' (a static one-shot). The handle
13
+ * owns the list (`items`) AND the editing `draft`: `edit(id)` / `create()` / `cancel()`,
14
+ * argless `save()` / `remove()` act on the draft, `save(row)` / `remove(row)` target a row,
15
+ * and the list reconciles itself (load = sorted upsert, paginate = refresh, listen = liveQuery).
16
+ *
17
+ * The options getter is reactive: change `where` / `orderBy` / `enabled` / `pageSize` and the
18
+ * query re-runs (stale in-flight responses dropped; `listen` re-subscribes). `orderBy` defaults
19
+ * to the entity's `defaultOrderBy`. `enabled: false` skips the query (keeps the last result)
20
+ * until it flips true. Read SvelteKit load `data` through a `$derived`, never raw in the getter.
21
+ *
22
+ * `.meta` is kept on every handle (captions / permissions / fields). There is NO `.repo`:
23
+ * everything imperative goes through remult's `repo(E)` directly (`insert` / `findId` / `count` /
24
+ * `deleteMany` / ...). The reactive shapes build an `$effect`, so create them at component init;
25
+ * for a click handler / async fn use `repo(E)`. A failed write fills `error` and re-throws.
26
+ *
27
+ * Internals (FF_RepoHandle modes load/live/paginate/one, the `.syncs` link, reconcilers) are
28
+ * private to this module; `ff` exposes only `many` / `one`.
29
+ */
30
+ /** The aggregate request shape - remult's `GroupByOptions` minus the grouping/paging keys. */
31
+ export type AggregateOptions<Entity> = Omit<GroupByOptions<Entity, never, NumericKeys<Entity>[], NumericKeys<Entity>[], (keyof MembersOnly<Entity>)[], (keyof MembersOnly<Entity>)[], (keyof MembersOnly<Entity>)[]>, 'group' | 'orderBy' | 'where' | 'limit' | 'page'>;
32
+ /**
33
+ * Remult `QueryOptions` plus the `aggregate` request shape. Exported for callers
34
+ * that build query options outside `ff` (e.g. a generic table component).
35
+ */
36
+ export type QueryOptionsHelper<Entity> = QueryOptions<Entity> & {
37
+ aggregate?: AggregateOptions<Entity>;
38
+ };
39
+ export type FF_RepoOptions<Entity> = {
40
+ where?: EntityFilter<Entity>;
41
+ orderBy?: EntityOrderBy<Entity>;
42
+ /** paginate: rows per page (default 25). */
43
+ pageSize?: number;
44
+ /** find/one/live: cap the rows returned. No default - returns every matching row. */
45
+ limit?: number;
46
+ include?: MembersToInclude<Entity>;
47
+ /** When false, the query is skipped (last result kept) until it flips true. */
48
+ enabled?: boolean;
49
+ /** Aggregations to compute alongside the page (paginate mode only). `$count` is always returned. */
50
+ aggregate?: AggregateOptions<Entity>;
51
+ };
52
+ type Getter<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = () => O;
53
+ /** `$count` is always present; richer keys appear only for the requested `aggregate`. */
54
+ type EmptyAggregateResult = {
55
+ $count: number;
56
+ };
57
+ /** The typed aggregate result for a given options object (paginate mode). */
58
+ type ExtractAggregateResult<Entity, O extends FF_RepoOptions<Entity>> = O extends {
59
+ aggregate: infer A;
60
+ } ? GroupByResult<Entity, never, A extends {
61
+ sum?: infer S;
62
+ } ? (S extends NumericKeys<Entity>[] ? S : never) : never, A extends {
63
+ avg?: infer V;
64
+ } ? (V extends NumericKeys<Entity>[] ? V : never) : never, A extends {
65
+ min?: infer M;
66
+ } ? (M extends NumericKeys<Entity>[] ? M : never) : never, A extends {
67
+ max?: infer X;
68
+ } ? (X extends NumericKeys<Entity>[] ? X : never) : never, A extends {
69
+ distinctCount?: infer D;
70
+ } ? D extends (keyof MembersOnly<Entity>)[] ? D : never : never> : EmptyAggregateResult;
71
+ export type FF_RepoLoading = {
72
+ init: boolean;
73
+ fetching: boolean;
74
+ more: boolean;
75
+ saving: boolean;
76
+ deleting: boolean;
77
+ };
78
+ type Mode = 'live' | 'load' | 'paginate' | 'one';
79
+ /**
80
+ * The reactive handle implementation (internal). `ff().one()` returns an Omit'd view of this
81
+ * (`FF_One`); `ff().many()` wraps a list handle + a `.syncs`-linked one in `FF_ManyHandle`
82
+ * (exposed as `FF_Many`). The per-mode aliases below stay internal to this module.
83
+ */
84
+ declare class FF_RepoHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> {
85
+ #private;
86
+ items: Entity[];
87
+ /** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
88
+ item: Entity | undefined;
89
+ loading: FF_RepoLoading;
90
+ error: string | undefined;
91
+ hasNextPage: boolean;
92
+ /** Aggregations for the whole query (paginate mode). `aggregates.$count` is the total row count. */
93
+ aggregates: ExtractAggregateResult<Entity, O> | undefined;
94
+ /** Any read or write currently in flight (init/fetching/more/saving/deleting). */
95
+ get isBusy(): boolean;
96
+ /** A write (insert/update/delete) in flight. */
97
+ get isWriting(): boolean;
98
+ constructor(r: Repository<Entity>, opts: Getter<Entity, O>, mode: Mode);
99
+ /** Re-run the current query (load/paginate/one), back to the first page. */
100
+ refresh(): Promise<void>;
101
+ /** Load and append the next page (paginate mode). */
102
+ more(): Promise<void>;
103
+ /**
104
+ * Run `fn` once - the first time a row exists (`items[0]`).
105
+ *
106
+ * The point: seed editable UI state from the latest row WITHOUT a live query
107
+ * clobbering in-progress edits. It fires a single time, on the first non-empty
108
+ * result, and never again - later ticks (an edit, a delete, a re-sort) are
109
+ * ignored. Empty snapshots are skipped (a liveQuery often emits one before the
110
+ * data lands; there is nothing to seed from an empty result).
111
+ *
112
+ * For pure derived state prefer `$derived`; reach for `onFirst` only when the
113
+ * seed must become independently editable (a draft the user then mutates).
114
+ *
115
+ * ```svelte
116
+ * const list = ff(Plan).many(() => ({ where: { ownerDid } }), 'listen')
117
+ * let draft = $state({ title: '' })
118
+ * list.onFirst((latest) => (draft.title = latest.title)) // seed once, then edit freely
119
+ * ```
120
+ */
121
+ onFirst(fn: (latest: Entity) => void): void;
122
+ /** Create a new unsaved entity into the `item` slot (for an edit form). */
123
+ create(...args: Parameters<Repository<Entity>['create']>): Entity;
124
+ /** Save the current `item` (from `one` / `create()`). To save a specific row, use remult `repo(E).save(row)`. */
125
+ save(): Promise<Entity>;
126
+ /** Delete the current `item`. To delete a specific row/id, use remult `repo(E).delete(idOrRow)`. */
127
+ delete(): Promise<void>;
128
+ /**
129
+ * Save a specific `row` through this list handle's loading/error machinery (mirrors the
130
+ * argless `save()`), then reconcile the list (sorted upsert / paginate refresh). Used by
131
+ * `FF_ManyHandle.save(target)` so a targeted write flips `loading.saving` and fills `error`.
132
+ */
133
+ saveRow(row: Entity): Promise<NonNullable<Entity>>;
134
+ /**
135
+ * Delete a specific `row` through this list handle's loading/error machinery (mirrors the
136
+ * argless `delete()`), then drop it from the list. Used by `FF_ManyHandle.remove(target)`.
137
+ */
138
+ deleteRow(row: Entity): Promise<void>;
139
+ /**
140
+ * Link this record handle (`one`) to one or more list handles. On `save()` /
141
+ * `delete()` the lists are reconciled (sorted upsert / remove) and share the
142
+ * write-loading flag, so the list area shows "busy" during the write too. A live
143
+ * list reconcile is a no-op (its liveQuery already syncs). Returns `this`.
144
+ */
145
+ syncs(...targets: Array<FF_RepoLoad<Entity> | FF_RepoPaginate<Entity> | FF_RepoLive<Entity>>): this;
146
+ /** Insert into `items` at `top` (default) / `bottom` / an index (`-1` = last). +1 to `$count`. */
147
+ addItem(item: Entity, options?: {
148
+ at?: 'top' | 'bottom' | number;
149
+ }): void;
150
+ /** Replace the row whose id matches `item`'s id (no `$count` change). */
151
+ updateItem(item: Entity): void;
152
+ /** Drop the matching row (pass an id or the item). -1 to `$count`. */
153
+ removeItem(idOrItem: Parameters<Repository<Entity>['delete']>[0]): void;
154
+ /**
155
+ * Insert-or-update `item` at its SORTED position (load mode), recomputing the index
156
+ * from this handle's `orderBy` plus the entity id as tiebreak. Paginate re-fetches
157
+ * (a row may belong to an unloaded page); live is a no-op (the liveQuery syncs).
158
+ */
159
+ reconcile(item: Entity): void;
160
+ /**
161
+ * The entity's remult metadata - the single escape hatch for everything not on
162
+ * this handle: permissions (`apiInsertAllowed()`, `apiUpdateAllowed(item)`,
163
+ * `apiDeleteAllowed(item)`, `apiReadAllowed`), `fields`, `idMetadata`, `options`,
164
+ * `key`. Reflects the current `remult.user`.
165
+ */
166
+ get meta(): EntityMetadata<Entity>;
167
+ /** Escape hatch to the underlying repo (count, findId, upsert, projections, ...). */
168
+ get repo(): Repository<Entity>;
169
+ }
170
+ /** load: one-shot list (`refresh()` to re-run) - a read+reconcile view. No paging/aggregates, and no `item`/`save`/`delete`/`create` (edit via `one`, write via remult `repo(E)`). */
171
+ export type FF_RepoLoad<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'item' | 'save' | 'delete' | 'create' | 'syncs'>;
172
+ /** live: reactive subscription, auto-updates - a read view. No refresh/paging/aggregates/reconcilers (the liveQuery does it), and no `item`/`save`/`delete`/`create`. */
173
+ export type FF_RepoLive<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'refresh' | 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem' | 'item' | 'save' | 'delete' | 'create' | 'reconcile' | 'syncs'>;
174
+ /** paginate: `more()` / `hasNextPage` / `aggregates` - a read+reconcile view. No `onFirst` (paged ≠ latest), and no `item`/`save`/`delete`/`create`. */
175
+ export type FF_RepoPaginate<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'onFirst' | 'item' | 'save' | 'delete' | 'create' | 'syncs'>;
176
+ /** one: a single reactive record in `item`. No paging / aggregates / list reconcilers. */
177
+ export type FF_RepoOne<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates' | 'addItem' | 'updateItem' | 'removeItem' | 'reconcile'>;
178
+ /** The list strategy backing `many`. Maps `listen` → live mode internally. */
179
+ export type ManyStrategy = 'load' | 'listen' | 'paginate';
180
+ /**
181
+ * Style 1 - unified composite. One handle owning a list (load/listen/paginate) AND
182
+ * the current editing `draft`, pre-wired: the draft handle `.syncs(list)`, so saving
183
+ * or deleting reconciles the list (sorted upsert / remove) and loading/error are
184
+ * merged across both. Proves the styles share internals: this is just a list handle
185
+ * plus a `.syncs()`-linked `one` handle.
186
+ *
187
+ * const t = ff(Task).many(() => ({ where }), 'load')
188
+ * t.edit(row) / t.create() / t.save() / t.remove(row) / t.cancel()
189
+ * markup reads t.items, t.draft, t.loading, t.isBusy, t.error
190
+ */
191
+ declare class FF_ManyHandle<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> {
192
+ #private;
193
+ constructor(r: Repository<Entity>, opts: Getter<Entity, O>, strategy: ManyStrategy);
194
+ get items(): Entity[];
195
+ get draft(): Entity | undefined;
196
+ set draft(v: Entity | undefined);
197
+ get error(): string | undefined;
198
+ get hasNextPage(): boolean;
199
+ get aggregates(): ExtractAggregateResult<Entity, O> | undefined;
200
+ /** Merged loading: list reads + draft writes. */
201
+ get loading(): FF_RepoLoading;
202
+ get isBusy(): boolean;
203
+ get isWriting(): boolean;
204
+ /**
205
+ * Load `row` into `draft` for editing. Pass the row itself (works with any PK,
206
+ * single or composite - the id is read off it).
207
+ *
208
+ * Default (no fetch): edits an isolated **clone** of `row` - instant, no flicker,
209
+ * and saving updates the original (the clone keeps remult's existing-row state).
210
+ * Cancelling just drops the clone, so the list row is untouched until save.
211
+ *
212
+ * `{ refetch: true }`: optimistic - put `row`'s structure into `draft` **immediately**
213
+ * (so a form renders at full size, no open-then-grow flicker), then re-read the row
214
+ * fresh from the data source and swap it in. The optimistic draft is marked as an
215
+ * **existing** row (rebuilt via json), so it works whether `row` is a tracked entity,
216
+ * a plain spread/`$state` object, or an id-only stub - and saving updates, never inserts.
217
+ * Use it when the list row may be stale or you only hold its id.
218
+ */
219
+ edit(row: Entity, opts?: {
220
+ refetch?: boolean;
221
+ }): void;
222
+ /** Start a blank `draft` (insert). */
223
+ create(...args: Parameters<Repository<Entity>['create']>): Entity;
224
+ /** Drop the draft / stop editing, and clear any pending error. */
225
+ cancel(): void;
226
+ /** Save `target` (any row) or, argless, the current `draft`; reconciles the list. */
227
+ save(target?: Entity): Promise<Entity>;
228
+ /** Delete `target` (any row) or, argless, the current `draft`; reconciles the list. */
229
+ remove(target?: Entity): Promise<void>;
230
+ /**
231
+ * Confirm, then remove `row`. Resolves `{ ok: true }` when removed, `{ ok: false }` when the
232
+ * user cancels OR the delete fails (a failure also fills `error` and, unless `toast: false`,
233
+ * shows `toast.fromError`). Never re-throws - safe for `onclick={() => list.confirmRemove(row)}`.
234
+ */
235
+ confirmRemove(row: Entity, opts?: {
236
+ message?: LocalizedMessage;
237
+ title?: LocalizedMessage;
238
+ confirmLabel?: LocalizedMessage;
239
+ cancelLabel?: LocalizedMessage;
240
+ /** Style the confirm as destructive. Default true (it's a delete). */
241
+ danger?: boolean;
242
+ /** Auto-show `toast.fromError` when the delete fails. Default true. */
243
+ toast?: boolean;
244
+ }): Promise<DialogResult<void>>;
245
+ /**
246
+ * Edit `row` in a dialog: seed `draft` (a clone, or `{ refetch: true }` to re-read fresh),
247
+ * open `body`, and always `cancel()` on close. The `body` snippet binds `draft` and calls
248
+ * `save()` itself (so a failed/validation save keeps the dialog open via `error`); this method
249
+ * owns only the seed + cleanup. Resolves the dialog's `DialogResult`.
250
+ */
251
+ editInDialog<T = unknown>(row: Entity, body: Snippet<[DialogClose<T>]>, opts?: DialogOptions & {
252
+ refetch?: boolean;
253
+ }): Promise<DialogResult<T>>;
254
+ /**
255
+ * Create in a dialog: start a blank `draft` (optionally seeded with `defaults`), open `body`,
256
+ * and always `cancel()` on close. The `body` binds `draft` and calls `save()` itself.
257
+ */
258
+ createInDialog<T = unknown>(body: Snippet<[DialogClose<T>]>, opts?: DialogOptions & {
259
+ defaults?: Parameters<Repository<Entity>['create']>[0];
260
+ }): Promise<DialogResult<T>>;
261
+ more(): Promise<void>;
262
+ refresh(): Promise<void>;
263
+ /**
264
+ * Seed editable state once, from the latest row (`items[0]`), the first time one
265
+ * lands - then never again, so a later live tick can't clobber an in-progress edit.
266
+ * Use it when the seed must become independently editable (a separate `$state` /
267
+ * `$bindable` the user then mutates), not the draft; for pure display prefer
268
+ * `$derived(handle.items[0])`. Delegates to the list handle (see `onFirst` there).
269
+ */
270
+ onFirst(fn: (latest: Entity) => void): void;
271
+ get meta(): EntityMetadata<Entity>;
272
+ get repo(): Repository<Entity>;
273
+ }
274
+ type StrictGetter<Entity, O extends FF_RepoOptions<Entity>> = () => O & Record<Exclude<keyof O, keyof FF_RepoOptions<Entity>>, never>;
275
+ /** The `ff(E).many(...)` handle. The `paginate` strategy adds `more`/`hasNextPage`/`aggregates`/`$count`. */
276
+ export type FF_Many<Entity, S extends ManyStrategy = 'paginate', O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_ManyHandle<Entity, O>, 'repo' | 'more' | 'hasNextPage' | 'aggregates'> & (S extends 'paginate' ? Pick<FF_ManyHandle<Entity, O>, 'more' | 'hasNextPage' | 'aggregates'> : Record<never, never>);
277
+ /** The `ff(E).one(...)` handle - a single reactive record in `item` (no list/repo/syncs). */
278
+ export type FF_One<Entity, O extends FF_RepoOptions<Entity> = FF_RepoOptions<Entity>> = Omit<FF_RepoOne<Entity, O>, 'repo' | 'syncs'>;
279
+ /** Builder from `ff(E)`: the two reactive shapes + `meta`. No `.repo` (use remult's `repo(E)`). */
280
+ export type FF_Builder<Entity> = {
281
+ /** A crud composite (list + editing draft + writes). `strategy` picks the fetch (default `paginate`). */
282
+ many: <O extends FF_RepoOptions<Entity>, S extends ManyStrategy = 'paginate'>(opts: StrictGetter<Entity, O>, strategy?: S) => FF_Many<Entity, S, O>;
283
+ /** A single reactive record bound to `item`. */
284
+ one: <O extends FF_RepoOptions<Entity>>(opts: StrictGetter<Entity, O>) => FF_One<Entity, O>;
285
+ /** The entity's remult metadata (captions, permissions, fields). */
286
+ readonly meta: EntityMetadata<Entity>;
287
+ };
288
+ /**
289
+ * `ff(E)` - the firstly reactive layer. `ff(E).many(getter, strategy?)` for a list+edit
290
+ * composite, `ff(E).one(getter)` for a single record. Everything imperative stays on
291
+ * remult's `repo(E)`.
292
+ */
293
+ export declare function ff<Entity>(entity: ClassType<Entity>): FF_Builder<Entity>;
294
+ export {};