firstly 0.5.1 → 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 +20 -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 +146 -55
  13. package/esm/svelte/DemoGrid.svelte.d.ts +10 -1
  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 +13 -2
  32. package/esm/svelte/index.js +8 -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 +2 -1
  39. package/esm/svelte/FF_Repo.svelte.d.ts +0 -198
  40. package/esm/svelte/FF_Repo.svelte.js +0 -305
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # firstly
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#286](https://github.com/jycouet/firstly/pull/286) [`0de3038`](https://github.com/jycouet/firstly/commit/0de3038769bd30f7449f3185d9556bb5966eea6f) Thanks [@jycouet](https://github.com/jycouet)! - Add `<FF_Config>` (`firstly/svelte`): an SSR-safe, context-scoped provider for app-wide UI config, read by firstly components during init. First consumer is the dialog: set default `confirm` / `cancel` / `ok` labels once and your `shell` / `confirm` / `prompt` snippets in one place, instead of passing them on every `dialog.confirm(...)` / `<FF_DialogManager>`.
8
+
9
+ Precedence is explicit prop > `<FF_Config>` > built-in. Pass message **functions** (paraglide / i18next) for labels and they re-resolve on every render, so locale changes are picked up for free. `dialog.confirm` / `dialog.prompt` no longer bake `'Confirm'` / `'Cancel'` / `'OK'` at call time - omitted labels resolve at render via the nearest `<FF_Config>` (then the built-in). Also exports `ffConfig()` (read) and `setFFConfig()` (advanced).
10
+
11
+ - [#286](https://github.com/jycouet/firstly/pull/286) [`d364fe2`](https://github.com/jycouet/firstly/commit/d364fe22211a2d7a7acbb58afc367bae1a7e911d) Thanks [@jycouet](https://github.com/jycouet)! - Add a headless async `dialog` layer (`firstly/svelte`): `dialog.show(body)`, `dialog.confirm(message)`, `dialog.prompt(opts)`, rendered through a single `<FF_DialogManager>` you mount once. Built-in defaults are theme-adaptive via semantic Tailwind tokens; pass `shell` / `confirm` / `prompt` snippets to fully restyle. Ships `ffAutofocus`, Escape/scroll-lock/stacking, and a `LocalizedMessage` (string or fn) for labels. `dialog.open(Component, { props })` opens a dialog from a component + props, inferring the result type from the component's `close: DialogClose<T>` prop (no call-site generic).
12
+
13
+ One result contract for all three: they resolve a `DialogResult` (`{ ok: true, data } | { ok: false }`). `confirm` carries no `data` (read `.ok`); `prompt`'s `data` is the trimmed string (so cancel vs empty-string is unambiguous - `{ ok: false }` vs `{ ok: true, data: '' }`); `show<T>` carries `T`. See `/docs/svelte/dialog`.
14
+
15
+ - [#286](https://github.com/jycouet/firstly/pull/286) [`fcafe26`](https://github.com/jycouet/firstly/commit/fcafe26833a018aea9e5fe2313120173a112eb80) Thanks [@jycouet](https://github.com/jycouet)! - Replace `ffRepo` with a cleaner `ff` surface: `ff(E).many(getter, strategy?)` (a list + editing draft + writes) and `ff(E).one(getter)` (a single bound record). `load`/`listen`/`paginate` are now the `strategy`, not separate verbs. Imperative work moves to remult's `repo(E)` (no `.repo` on the handle); `.meta` stays. Adds an exported `DemoForm` alongside `DemoGrid`.
16
+
17
+ ### Patch Changes
18
+
19
+ - [#288](https://github.com/jycouet/firstly/pull/288) [`7133d3c`](https://github.com/jycouet/firstly/commit/7133d3c31c276f2932a8c7efeaca3cd5e05a51b5) Thanks [@jycouet](https://github.com/jycouet)! - Add `many` action+confirm orchestration and a `toast` (`firstly/svelte`):
20
+ - `ff(E).many().confirmRemove(row, opts?)` (confirm → remove → auto error-toast, never re-throws), `editInDialog(row, body, opts?)` and `createInDialog(body, opts?)` (seed draft → `dialog.show` → cancel on close).
21
+ - `toast` + `<FF_ToastManager>` - a `LocalizedMessage`-aware wrapper over svelte-sonner (a new direct dependency). First arg is the **description** (HTML allowed); the bold **title** moves to `opts.title` and defaults per kind, localizable via `<FF_Config messages.toast>`. See `/docs/svelte/toast`.
22
+
3
23
  ## 0.5.1
4
24
 
5
25
  ### Patch Changes
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
5
5
  * pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
6
- * is a search-box helper (pairs with `ffRepo(...).load/paginate`).
6
+ * is a search-box helper (pairs with `ff(...).many`).
7
7
  *
8
8
  * Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
9
9
  * autocompletion and type-safety on the column name. Without a generic the
@@ -61,7 +61,7 @@ export declare const FF_Filter: {
61
61
  *
62
62
  * @example
63
63
  * ```ts
64
- * const r = ffRepo(User).load(() => ({
64
+ * const r = ff(User).many(() => ({
65
65
  * where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
66
66
  * }))
67
67
  * ```
@@ -5,7 +5,7 @@ import { containsWords } from './containsWords.js';
5
5
  *
6
6
  * `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
7
7
  * pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
8
- * is a search-box helper (pairs with `ffRepo(...).load/paginate`).
8
+ * is a search-box helper (pairs with `ff(...).many`).
9
9
  *
10
10
  * Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
11
11
  * autocompletion and type-safety on the column name. Without a generic the
@@ -64,7 +64,7 @@ export const FF_Filter = {
64
64
  *
65
65
  * @example
66
66
  * ```ts
67
- * const r = ffRepo(User).load(() => ({
67
+ * const r = ff(User).many(() => ({
68
68
  * where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
69
69
  * }))
70
70
  * ```
@@ -18,6 +18,8 @@ export type EmailMessages = {
18
18
  export type ValidatorMessages = {
19
19
  email?: EmailMessages;
20
20
  };
21
+ /** Resolve a `LocalizedMessage` (literal, or a paraglide/i18next/lingui message fn) to its current string value. */
22
+ export declare const resolveMessage: (m: LocalizedMessage) => string;
21
23
  /**
22
24
  * Build a project-localized set of validators. Override any subset of
23
25
  * messages; defaults are English.
@@ -24,10 +24,8 @@ const DEFAULT_EMAIL_MESSAGES = {
24
24
  blockedDomain: 'Test/example email not accepted',
25
25
  blockedTld: 'Test/example TLD not accepted',
26
26
  };
27
- /** Resolve a `LocalizedMessage` to its current string value. */
28
- function resolve(m) {
29
- return typeof m === 'function' ? m() : m;
30
- }
27
+ /** Resolve a `LocalizedMessage` (literal, or a paraglide/i18next/lingui message fn) to its current string value. */
28
+ export const resolveMessage = (m) => (typeof m === 'function' ? m() : m);
31
29
  /**
32
30
  * Build a project-localized set of validators. Override any subset of
33
31
  * messages; defaults are English.
@@ -66,26 +64,26 @@ export function createValidators(messages = {}) {
66
64
  if (value === '')
67
65
  return true;
68
66
  if (!EMAIL_SHAPE_RE.test(value))
69
- return resolve(m.invalid);
67
+ return resolveMessage(m.invalid);
70
68
  const at = value.lastIndexOf('@');
71
69
  const domain = value.slice(at + 1).toLowerCase();
72
70
  if (!domain || domain.includes('..') || domain.startsWith('.') || domain.endsWith('.')) {
73
- return resolve(m.invalidDomain);
71
+ return resolveMessage(m.invalidDomain);
74
72
  }
75
73
  if (BLOCKED_EMAIL_DOMAINS.has(domain))
76
- return resolve(m.blockedDomain);
74
+ return resolveMessage(m.blockedDomain);
77
75
  const tld = domain.split('.').pop() ?? '';
78
76
  if (BLOCKED_EMAIL_TLDS.has(tld))
79
- return resolve(m.blockedTld);
77
+ return resolveMessage(m.blockedTld);
80
78
  if (!domain.includes('.') || tld.length < 2)
81
- return resolve(m.invalidDomain);
79
+ return resolveMessage(m.invalidDomain);
82
80
  return true;
83
81
  }
84
82
  return {
85
83
  checkEmail,
86
84
  // Pass a function so the per-request locale is resolved when the
87
85
  // validator actually fires, not when the validator is built.
88
- email: createValueValidator(checkEmail, () => resolve(m.invalid)),
86
+ email: createValueValidator(checkEmail, () => resolveMessage(m.invalid)),
89
87
  };
90
88
  }
91
89
  /**
@@ -7,7 +7,7 @@ import type { EntityFilter, FieldMetadata } from 'remult';
7
7
  * (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
8
8
  *
9
9
  * So word order and which field holds which word don't matter - handy for
10
- * "NOM Prénom" style search across several columns. Pairs with `ffRepo`:
11
- * `ffRepo(User).load(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
10
+ * "NOM Prénom" style search across several columns. Pairs with `ff`:
11
+ * `ff(User).many(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
12
12
  */
13
13
  export declare const containsWords: <Entity>(fields: FieldMetadata<unknown, Entity>[], search: string) => EntityFilter<Entity>;
@@ -6,8 +6,8 @@
6
6
  * (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
7
7
  *
8
8
  * So word order and which field holds which word don't matter - handy for
9
- * "NOM Prénom" style search across several columns. Pairs with `ffRepo`:
10
- * `ffRepo(User).load(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
9
+ * "NOM Prénom" style search across several columns. Pairs with `ff`:
10
+ * `ff(User).many(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
11
11
  */
12
12
  export const containsWords = (fields, search) => {
13
13
  const words = (search ?? '').split(' ').filter((s) => s.length > 0);
@@ -11,11 +11,10 @@ import { type ClassValue } from 'clsx';
11
11
  * import { tw } from './tailwind'
12
12
  *
13
13
  * const buttonClasses = tw(
14
- * 'btn btn-primary',
15
- * isDisabled && 'btn-disabled',
16
- * size === 'lg' && 'btn-lg'
14
+ * 'px-4 py-2 bg-primary text-primary-foreground',
15
+ * isDisabled && 'opacity-50 pointer-events-none',
16
+ * size === 'lg' && 'px-6 text-lg'
17
17
  * )
18
- * // Result: "btn btn-primary btn-disabled" (if isDisabled is true)
19
18
  * ```
20
19
  */
21
20
  export declare const tw: (...inputs: ClassValue[]) => string;
@@ -12,11 +12,10 @@ import { twMerge } from 'tailwind-merge';
12
12
  * import { tw } from './tailwind'
13
13
  *
14
14
  * const buttonClasses = tw(
15
- * 'btn btn-primary',
16
- * isDisabled && 'btn-disabled',
17
- * size === 'lg' && 'btn-lg'
15
+ * 'px-4 py-2 bg-primary text-primary-foreground',
16
+ * isDisabled && 'opacity-50 pointer-events-none',
17
+ * size === 'lg' && 'px-6 text-lg'
18
18
  * )
19
- * // Result: "btn btn-primary btn-disabled" (if isDisabled is true)
20
19
  * ```
21
20
  */
22
21
  export const tw = (...inputs) => twMerge(clsx(...inputs));
@@ -0,0 +1,121 @@
1
+ <script lang="ts" generics="T extends { id: string }">
2
+ import type { ClassType, EntityFilter } from 'remult'
3
+
4
+ import { ff } from './ff.svelte.js'
5
+
6
+ type Props = {
7
+ /** The remult entity class. */
8
+ entity: ClassType<T>
9
+ /** Fields to edit. Labels come from each field's `caption`. */
10
+ fields: (keyof T & string)[]
11
+ /** Which record (remult `EntityFilter`). Default: findFirst by `defaultOrderBy` (the latest). */
12
+ where?: EntityFilter<T>
13
+ }
14
+ let { entity, fields, where }: Props = $props()
15
+
16
+ // A single bound record: `where` (or findFirst by the entity's defaultOrderBy - the latest row).
17
+ const r = ff(entity).one(() => ({ where }))
18
+
19
+ const get = (row: T, f: keyof T & string) => (row as Record<string, unknown>)[f]
20
+ function setField(f: keyof T & string, value: string) {
21
+ if (r.item) (r.item as Record<string, unknown>)[f] = value
22
+ }
23
+ </script>
24
+
25
+ <div class="form">
26
+ {#if r.loading.init}
27
+ <p class="muted">Loading…</p>
28
+ {:else if r.item}
29
+ {#each fields as f (f)}
30
+ <label>
31
+ <span>{r.meta.fields.find(f)?.caption ?? f}</span>
32
+ <input
33
+ value={String(get(r.item, f) ?? '')}
34
+ oninput={(e) => setField(f, e.currentTarget.value)}
35
+ />
36
+ </label>
37
+ {/each}
38
+ <div class="actions">
39
+ <button
40
+ disabled={r.isWriting ||
41
+ (r.item.id ? !r.meta.apiUpdateAllowed(r.item) : !r.meta.apiInsertAllowed(r.item))}
42
+ onclick={() => r.save()}
43
+ >
44
+ Save
45
+ </button>
46
+ {#if r.isWriting}<span class="busy">saving…</span>{/if}
47
+ </div>
48
+ {#if r.error}<p class="err">{r.error}</p>{/if}
49
+ {:else}
50
+ <p class="muted">No record yet.</p>
51
+ <button disabled={!r.meta.apiInsertAllowed()} onclick={() => r.create()}>+ New</button>
52
+ {/if}
53
+ </div>
54
+
55
+ <style>
56
+ .form {
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: 12px;
60
+ align-items: stretch;
61
+ font-size: 14px;
62
+ }
63
+ label {
64
+ display: flex;
65
+ flex-direction: column;
66
+ gap: 4px;
67
+ }
68
+ label span {
69
+ font-size: 12px;
70
+ opacity: 0.65;
71
+ }
72
+ input {
73
+ width: 100%;
74
+ box-sizing: border-box;
75
+ padding: 7px 10px;
76
+ font: inherit;
77
+ color: inherit;
78
+ background: transparent;
79
+ border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
80
+ border-radius: 8px;
81
+ }
82
+ input:focus-visible {
83
+ outline: none;
84
+ border-color: color-mix(in srgb, currentColor 55%, transparent);
85
+ }
86
+ .actions {
87
+ display: flex;
88
+ gap: 10px;
89
+ align-items: center;
90
+ }
91
+ .busy {
92
+ color: #d97706;
93
+ font-weight: 600;
94
+ font-size: 12px;
95
+ }
96
+ .err {
97
+ color: #dc2626;
98
+ margin: 0;
99
+ font-size: 13px;
100
+ }
101
+ button {
102
+ cursor: pointer;
103
+ font: inherit;
104
+ padding: 6px 14px;
105
+ color: inherit;
106
+ background: color-mix(in srgb, currentColor 8%, transparent);
107
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
108
+ border-radius: 8px;
109
+ transition: background 0.14s ease;
110
+ }
111
+ button:hover {
112
+ background: color-mix(in srgb, currentColor 15%, transparent);
113
+ }
114
+ button:disabled {
115
+ opacity: 0.45;
116
+ cursor: not-allowed;
117
+ }
118
+ .muted {
119
+ opacity: 0.6;
120
+ }
121
+ </style>
@@ -0,0 +1,42 @@
1
+ import type { ClassType, EntityFilter } from 'remult';
2
+ declare function $$render<T extends {
3
+ id: string;
4
+ }>(): {
5
+ props: {
6
+ /** The remult entity class. */
7
+ entity: ClassType<T>;
8
+ /** Fields to edit. Labels come from each field's `caption`. */
9
+ fields: (keyof T & string)[];
10
+ /** Which record (remult `EntityFilter`). Default: findFirst by `defaultOrderBy` (the latest). */
11
+ where?: EntityFilter<T>;
12
+ };
13
+ exports: {};
14
+ bindings: "";
15
+ slots: {};
16
+ events: {};
17
+ };
18
+ declare class __sveltets_Render<T extends {
19
+ id: string;
20
+ }> {
21
+ props(): ReturnType<typeof $$render<T>>['props'];
22
+ events(): ReturnType<typeof $$render<T>>['events'];
23
+ slots(): ReturnType<typeof $$render<T>>['slots'];
24
+ bindings(): "";
25
+ exports(): {};
26
+ }
27
+ interface $$IsomorphicComponent {
28
+ new <T extends {
29
+ id: string;
30
+ }>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
31
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
32
+ } & ReturnType<__sveltets_Render<T>['exports']>;
33
+ <T extends {
34
+ id: string;
35
+ }>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
36
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
37
+ }
38
+ declare const DemoForm: $$IsomorphicComponent;
39
+ type DemoForm<T extends {
40
+ id: string;
41
+ }> = InstanceType<typeof DemoForm<T>>;
42
+ export default DemoForm;
@@ -1,50 +1,48 @@
1
1
  <script lang="ts" generics="T extends { id: string }">
2
- import type { ClassType, EntityFilter, MembersOnly } from 'remult'
2
+ import { untrack } from 'svelte'
3
3
 
4
- import { ffRepo } from './FF_Repo.svelte.js'
4
+ import type { ClassType, EntityFilter } from 'remult'
5
+
6
+ import { ff, type FF_Many, type ManyStrategy } from './ff.svelte.js'
5
7
 
6
8
  type Props = {
7
9
  /** The remult entity class to CRUD. */
8
10
  entity: ClassType<T>
9
11
  /** Fields to show as columns / edit inputs. Headers come from each field's `caption`. */
10
12
  fields: (keyof T & string)[]
13
+ /** Filter the list (remult `EntityFilter`). Reactive: change it to re-fetch. */
14
+ where?: EntityFilter<T>
15
+ /** Fetch strategy: `paginate` (default), `listen` (live), or `load` (static one-shot). */
16
+ strategy?: ManyStrategy
17
+ /** When false the list query is skipped (no auto-load) until it flips true. */
18
+ enabled?: boolean
19
+ /** Rows per page (paginate strategy). */
20
+ pageSize?: number
11
21
  }
12
- let { entity, fields }: Props = $props()
22
+ let {
23
+ entity,
24
+ fields,
25
+ where,
26
+ strategy = 'paginate',
27
+ enabled = true,
28
+ pageSize = 25,
29
+ }: Props = $props()
13
30
 
14
- // live list - any write re-emits it, so no reconcile code (entity is static config)
15
- const list = ffRepo(entity).listen(() => ({}))
31
+ // ONE handle does it all: list (per strategy) + editing draft + writes.
32
+ // `entity` + `strategy` are init-only - the handle builds its `$effect` once; the
33
+ // reactive bits live in the getter (`where`/`enabled`/`pageSize`, read on each run).
34
+ // `untrack` says "read these once on purpose" so Svelte doesn't flag a stale capture.
35
+ // Cast to the paginate view so `more()`/`aggregates`/`refresh` are reachable; guarded below.
36
+ const m = untrack(() =>
37
+ ff(entity).many(() => ({ where, enabled, pageSize }), strategy),
38
+ ) as unknown as FF_Many<T, 'paginate'>
16
39
 
17
- // the row being edited (by id), or a fresh draft for "new" - one reactive slot
18
- let editingId = $state<string | null>(null)
19
- const editor = ffRepo(entity).one(() => ({
20
- where: { id: editingId ?? '' } as EntityFilter<T>,
21
- enabled: !!editingId,
22
- }))
23
- const creating = $derived(!!editor.item && !editor.item.id)
40
+ const creating = $derived(!!m.draft && !m.draft.id)
24
41
 
25
42
  // dynamic read/write of a field by key (v1: inputs are text)
26
43
  const get = (row: T, f: keyof T & string) => (row as Record<string, unknown>)[f]
27
44
  function setField(f: keyof T & string, value: string) {
28
- if (editor.item) (editor.item as Record<string, unknown>)[f] = value
29
- }
30
-
31
- function edit(id: string) {
32
- editingId = id // → editor.item loads that row
33
- }
34
- function add() {
35
- editingId = null
36
- editor.create() // blank draft into editor.item
37
- }
38
- function cancel() {
39
- editingId = null
40
- editor.item = undefined // drop the draft / stop editing
41
- }
42
- async function save() {
43
- await editor.save() // insert (a draft) or update (the loaded row); the live list self-syncs
44
- cancel()
45
- }
46
- async function remove(row: T) {
47
- await list.repo.delete(row as Partial<MembersOnly<T>>) // raw delete via .repo; the live list drops the row
45
+ if (m.draft) (m.draft as Record<string, unknown>)[f] = value
48
46
  }
49
47
  </script>
50
48
 
@@ -52,51 +50,98 @@
52
50
  {#each fields as f (f)}
53
51
  <td>
54
52
  <input
55
- placeholder={list.meta.fields.find(f)?.caption ?? f}
53
+ placeholder={m.meta.fields.find(f)?.caption ?? f}
56
54
  value={String(get(draft, f) ?? '')}
57
55
  oninput={(e) => setField(f, e.currentTarget.value)}
58
56
  />
59
57
  </td>
60
58
  {/each}
61
59
  <td class="actions">
62
- <button disabled={editor.loading.saving} onclick={save}>Save</button>
63
- <button onclick={cancel}>Cancel</button>
60
+ <button
61
+ disabled={m.isWriting ||
62
+ (draft.id ? !m.meta.apiUpdateAllowed(draft) : !m.meta.apiInsertAllowed(draft))}
63
+ onclick={() => m.save()}
64
+ >
65
+ Save
66
+ </button>
67
+ <button onclick={() => m.cancel()}>Cancel</button>
64
68
  </td>
65
69
  {/snippet}
66
70
 
67
71
  <div class="crud">
68
- <button class="new" onclick={add}>+ New</button>
72
+ <div class="bar">
73
+ <button disabled={!m.meta.apiInsertAllowed()} onclick={() => m.create()}>+ New</button>
74
+ {#if strategy !== 'listen'}
75
+ <button onclick={() => m.refresh()}>Refresh</button>
76
+ {/if}
77
+ {#if strategy === 'paginate' && m.aggregates}<span class="count">{m.aggregates.$count} rows</span
78
+ >{/if}
79
+ {#if m.isBusy}<span class="busy">busy…</span>{/if}
80
+ </div>
81
+
82
+ {#if m.error}<p class="err">{m.error}</p>{/if}
69
83
 
70
84
  <table>
71
85
  <thead>
72
86
  <tr>
73
- {#each fields as f (f)}<th>{list.meta.fields.find(f)?.caption ?? f}</th>{/each}
87
+ {#each fields as f (f)}<th>{m.meta.fields.find(f)?.caption ?? f}</th>{/each}
74
88
  <th></th>
75
89
  </tr>
76
90
  </thead>
77
91
  <tbody>
78
- {#if creating && editor.item}
79
- <tr class="editing">{@render editRow(editor.item)}</tr>
92
+ {#if creating && m.draft}
93
+ <tr class="editing">{@render editRow(m.draft)}</tr>
94
+ {/if}
95
+ {#if m.loading.init}
96
+ <!--
97
+ First-load placeholder. We know `fields`, so we render one shimmer cell per column
98
+ plus a button-sized cell in the actions column - that keeps each skeleton row the
99
+ same height as a real row, so the table doesn't jump when the data arrives.
100
+ -->
101
+ {#each Array(2) as _, i (i)}
102
+ <tr>
103
+ {#each fields as f (f)}<td><span class="sk"></span></td>{/each}
104
+ <td class="actions"><span class="sk sk-btn"></span></td>
105
+ </tr>
106
+ {/each}
107
+ {:else}
108
+ {#each m.items as row (row.id)}
109
+ <tr class:editing={m.draft?.id === row.id}>
110
+ {#if m.draft && m.draft.id === row.id}
111
+ {@render editRow(m.draft)}
112
+ {:else}
113
+ {#each fields as f (f)}<td>{get(row, f)}</td>{/each}
114
+ <td class="actions">
115
+ <button disabled={!m.meta.apiUpdateAllowed(row)} onclick={() => m.edit(row)}> Edit </button>
116
+ <button disabled={!m.meta.apiDeleteAllowed(row)} onclick={() => m.remove(row)}>
117
+ Delete
118
+ </button>
119
+ </td>
120
+ {/if}
121
+ </tr>
122
+ {/each}
80
123
  {/if}
81
- {#each list.items as row (row.id)}
82
- <tr class:editing={editingId === row.id}>
83
- {#if editingId === row.id && editor.item}
84
- {@render editRow(editor.item)}
85
- {:else}
86
- {#each fields as f (f)}<td>{get(row, f)}</td>{/each}
87
- <td class="actions">
88
- <button onclick={() => edit(row.id)}>Edit</button>
89
- <button onclick={() => remove(row)}>Delete</button>
90
- </td>
91
- {/if}
92
- </tr>
93
- {/each}
94
124
  </tbody>
95
125
  </table>
96
126
 
97
- {#if list.loading.init}
98
- <p class="muted">Loading…</p>
99
- {:else if list.items.length === 0 && !creating}
127
+ {#if strategy === 'paginate' && m.hasNextPage}
128
+ <!--
129
+ Manual "More". To auto-load on scroll instead, use the `infiniteScroll` attachment
130
+ (also from 'firstly/svelte') on a bottom sentinel:
131
+ <div {@attach infiniteScroll({
132
+ hasMore: () => m.hasNextPage,
133
+ loading: () => m.loading.more,
134
+ onMore: () => m.more(),
135
+ })}></div>
136
+ -->
137
+ <button disabled={m.loading.more} onclick={() => m.more()}>
138
+ {m.loading.more ? 'Loading…' : 'More'}
139
+ </button>
140
+ {/if}
141
+
142
+ {#if !enabled}
143
+ <p class="muted">Not loaded (<code>enabled: false</code>) - flip it to fetch.</p>
144
+ {:else if !m.loading.init && m.items.length === 0 && !creating}
100
145
  <p class="muted">Nothing yet - hit “+ New”.</p>
101
146
  {/if}
102
147
  </div>
@@ -109,6 +154,27 @@
109
154
  gap: 10px;
110
155
  align-items: start;
111
156
  }
157
+ .bar {
158
+ display: flex;
159
+ gap: 10px;
160
+ align-items: center;
161
+ flex-wrap: wrap;
162
+ width: 100%;
163
+ }
164
+ .count {
165
+ font-size: 12px;
166
+ font-variant-numeric: tabular-nums;
167
+ opacity: 0.85;
168
+ }
169
+ .busy {
170
+ font-size: 12px;
171
+ color: #f59e0b;
172
+ font-weight: 600;
173
+ }
174
+ .err {
175
+ color: #ef4444;
176
+ margin: 0;
177
+ }
112
178
  table {
113
179
  border-collapse: collapse;
114
180
  width: 100%;
@@ -164,4 +230,29 @@
164
230
  .muted {
165
231
  opacity: 0.6;
166
232
  }
233
+ .sk {
234
+ display: inline-block;
235
+ width: 70%;
236
+ height: 0.8em;
237
+ border-radius: 4px;
238
+ background: color-mix(in srgb, currentColor 16%, transparent);
239
+ animation: sk-pulse 1.2s ease-in-out infinite;
240
+ }
241
+ /* button-sized so a skeleton row matches a real (button-bearing) row's height */
242
+ .sk-btn {
243
+ width: 3.4em;
244
+ height: 1.9em;
245
+ }
246
+ @keyframes sk-pulse {
247
+ 0%,
248
+ 100% {
249
+ opacity: 0.45;
250
+ }
251
+ 50% {
252
+ opacity: 0.85;
253
+ }
254
+ }
255
+ code {
256
+ font-size: 12px;
257
+ }
167
258
  </style>
@@ -1,4 +1,5 @@
1
- import type { ClassType } from 'remult';
1
+ import type { ClassType, EntityFilter } from 'remult';
2
+ import { type ManyStrategy } from './ff.svelte.js';
2
3
  declare function $$render<T extends {
3
4
  id: string;
4
5
  }>(): {
@@ -7,6 +8,14 @@ declare function $$render<T extends {
7
8
  entity: ClassType<T>;
8
9
  /** Fields to show as columns / edit inputs. Headers come from each field's `caption`. */
9
10
  fields: (keyof T & string)[];
11
+ /** Filter the list (remult `EntityFilter`). Reactive: change it to re-fetch. */
12
+ where?: EntityFilter<T>;
13
+ /** Fetch strategy: `paginate` (default), `listen` (live), or `load` (static one-shot). */
14
+ strategy?: ManyStrategy;
15
+ /** When false the list query is skipped (no auto-load) until it flips true. */
16
+ enabled?: boolean;
17
+ /** Rows per page (paginate strategy). */
18
+ pageSize?: number;
10
19
  };
11
20
  exports: {};
12
21
  bindings: "";
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import type { DialogClose } from './dialog.svelte.js'
3
+
4
+ // `label` proves props pass through; `close` is injected by the manager and typed,
5
+ // so `open(DialogOpenTest)` resolves `DialogResult<number>`.
6
+ let { label, close }: { label: string; close: DialogClose<number> } = $props()
7
+ </script>
8
+
9
+ <p data-testid="open-label">{label}</p>
10
+ <button data-testid="open-ok" onclick={() => close({ ok: true, data: 7 })}>ok</button>
@@ -0,0 +1,8 @@
1
+ import type { DialogClose } from './dialog.svelte.js';
2
+ type $$ComponentProps = {
3
+ label: string;
4
+ close: DialogClose<number>;
5
+ };
6
+ declare const DialogOpenTest: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type DialogOpenTest = ReturnType<typeof DialogOpenTest>;
8
+ export default DialogOpenTest;
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+
4
+ import { setFFConfig, type FF_ConfigValue } from './FF_Config.svelte.js'
5
+
6
+ let { children, messages, dialog, toast }: { children: Snippet } & FF_ConfigValue = $props()
7
+
8
+ // Hand context a getter over our (reactive) props, not a snapshot - so descendants re-read
9
+ // the latest values and locale-aware message functions resolve fresh on each render.
10
+ setFFConfig(() => ({ messages, dialog, toast }))
11
+ </script>
12
+
13
+ {@render children()}
@@ -0,0 +1,3 @@
1
+ declare const FFConfig: import("svelte").Component<any, {}, "">;
2
+ type FFConfig = ReturnType<typeof FFConfig>;
3
+ export default FFConfig;