runeforge 0.0.61 → 0.0.63

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 (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1410 -1407
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +73 -73
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +203 -203
  8. package/dist/components/crud/Field.svelte +122 -122
  9. package/dist/components/crud/GenericCRUD.svelte +426 -426
  10. package/dist/components/crud/SearchInput.svelte +53 -53
  11. package/dist/components/crud/columns/Avatar.svelte +15 -15
  12. package/dist/components/crud/columns/Icon.svelte +8 -8
  13. package/dist/components/crud/fields/BooleanField.svelte +38 -38
  14. package/dist/components/crud/fields/DatetimeField.svelte +250 -250
  15. package/dist/components/crud/fields/DefaultField.svelte +67 -67
  16. package/dist/components/crud/fields/FileField.svelte +70 -70
  17. package/dist/components/crud/fields/MultiSelectField.svelte +70 -70
  18. package/dist/components/crud/fields/SelectField.svelte +67 -67
  19. package/dist/components/crud/fields/TextareaField.svelte +55 -55
  20. package/dist/components/crud/fields/TreeField.svelte +1 -0
  21. package/dist/components/crud/utils/resolution.js +1 -0
  22. package/dist/components/crud/utils/tree.d.ts +5 -0
  23. package/dist/components/crud/utils/tree.js +29 -0
  24. package/dist/components/crud/views/Create.svelte +239 -239
  25. package/dist/components/crud/views/Read.svelte +131 -131
  26. package/dist/components/crud/views/Update.svelte +215 -215
  27. package/dist/components/crud/views/list/List.svelte +291 -291
  28. package/dist/components/crud/views/list/Modals.svelte +56 -56
  29. package/dist/components/crud/views/list/Table.svelte +173 -173
  30. package/dist/components/crud/views/list/Toolbar.svelte +340 -340
  31. package/dist/components/form/Button.svelte +27 -27
  32. package/dist/components/form/Label.svelte +37 -37
  33. package/dist/components/form/MultiSelect.svelte +248 -248
  34. package/dist/components/form/PasswordInput.svelte +68 -68
  35. package/dist/components/form/Required.svelte +1 -1
  36. package/dist/components/form/Select.svelte +209 -209
  37. package/dist/components/form/Tree.svelte +43 -4
  38. package/dist/components/form/Tree.svelte.d.ts +1 -0
  39. package/dist/components/form/TreeNode.svelte +31 -2
  40. package/dist/components/form/TreeNode.svelte.d.ts +2 -0
  41. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  42. package/dist/components/table/ColumnFilter.svelte +168 -168
  43. package/dist/components/table/PaginatedTable.svelte +542 -542
  44. package/dist/components/table/Paginator.svelte +113 -113
  45. package/dist/components/table/SortHeader.svelte +43 -43
  46. package/dist/components/table/TableBody.svelte +150 -150
  47. package/dist/components/table/TableHeader.svelte +88 -88
  48. package/dist/components/table/state.svelte.js +7 -2
  49. package/dist/icons/defaults/Clear.svelte +6 -6
  50. package/dist/icons/defaults/Create.svelte +6 -6
  51. package/dist/icons/defaults/Delete.svelte +6 -6
  52. package/dist/icons/defaults/Download.svelte +7 -7
  53. package/dist/icons/defaults/Edit.svelte +7 -7
  54. package/dist/icons/defaults/Filter.svelte +6 -6
  55. package/dist/icons/defaults/FilterActive.svelte +6 -6
  56. package/dist/icons/defaults/Folder.svelte +6 -6
  57. package/dist/icons/defaults/Grip.svelte +7 -7
  58. package/dist/icons/defaults/Home.svelte +6 -6
  59. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  60. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  61. package/dist/icons/defaults/SortAsc.svelte +6 -6
  62. package/dist/icons/defaults/SortDesc.svelte +6 -6
  63. package/dist/icons/defaults/SortNone.svelte +6 -6
  64. package/dist/icons/defaults/View.svelte +7 -7
  65. package/dist/types/attribute.d.ts +3 -0
  66. package/dist/types/crud/fields.d.ts +3 -0
  67. package/package.json +1 -1
@@ -1,203 +1,203 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { untrack } from 'svelte';
3
- import Field from './Field.svelte';
4
- import Button from '../form/Button.svelte';
5
- import Modal from '../Modal.svelte';
6
- import { emptyRecord, seedField, defaultItemLabel } from './utils/embedded.js';
7
- import { fieldLabel } from './utils/misc.js';
8
- import { validateAll } from './utils/validation.js';
9
- import { groupFields } from './utils/grouping.js';
10
- import type { FieldDefinition } from '../../types/crud/fields.js';
11
- import { getStrings } from '../../i18n/context.js';
12
-
13
- const strings = getStrings();
14
-
15
- let {
16
- field,
17
- record = $bindable({} as Record<string, unknown>),
18
- readonly = false
19
- }: {
20
- field: FieldDefinition<T>;
21
- /** The record that owns this embedded field (the form record for a
22
- * top-level embedded field, or the outer item's draft for one nested
23
- * inside another embedded field) — passed through to each sub-field's
24
- * `hidden`/`disabled`/`required`/`dependentOptions` as their `parent`
25
- * argument, and to `field.revalidate` as its own parent argument. */
26
- record?: Record<string, unknown>;
27
- readonly?: boolean;
28
- } = $props();
29
-
30
- const subFields = $derived(field.fields ?? []);
31
- const subGroups = $derived(groupFields(subFields));
32
- const items = $derived((record[field.attribute] as Record<string, unknown>[] | undefined) ?? []);
33
-
34
- // Re-derives the embedded list right after `field.dependsOn` changes value
35
- // on the parent record — e.g. dropping items that no longer apply once a
36
- // sibling "owner kind" select flips — instead of leaving them stale until
37
- // the user happens to touch this field again. Scoped to exactly that one
38
- // parent attribute: reading it is this effect's only tracked dependency,
39
- // and everything else (including its own write to `record[field.attribute]`)
40
- // runs inside `untrack` so the effect never re-triggers itself.
41
- $effect(() => {
42
- if (!field.dependsOn || !field.revalidate) return;
43
- // Read (only) to register as this effect's sole tracked dependency —
44
- // everything that actually acts on it happens inside untrack() below,
45
- // so the effect reruns exactly when this one value changes and never
46
- // because of its own write to record[field.attribute].
47
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
48
- const dependencyValue = record[field.dependsOn];
49
- untrack(() => {
50
- const current = (record[field.attribute] as Record<string, unknown>[] | undefined) ?? [];
51
- const next = field.revalidate!(current, record);
52
- if (next !== current) record[field.attribute] = next;
53
- });
54
- });
55
-
56
- let modalOpen = $state(false);
57
- // null while adding a new item; the item's index while editing an existing
58
- // one, so saveItem knows whether to append or replace in place.
59
- let editingIndex = $state<number | null>(null);
60
- let draft = $state<Record<string, unknown>>({});
61
- let draftErrors = $state<Record<string, string>>({});
62
-
63
- function openCreateModal() {
64
- editingIndex = null;
65
- draft = emptyRecord(subFields);
66
- draftErrors = {};
67
- modalOpen = true;
68
- }
69
-
70
- function openEditModal(index: number) {
71
- const item = items[index];
72
- editingIndex = index;
73
- draft = Object.fromEntries(
74
- subFields.map((f) => [f.attribute, seedField(f, f.seed ? f.seed(item) : item[f.attribute])])
75
- );
76
- draftErrors = {};
77
- modalOpen = true;
78
- }
79
-
80
- function closeModal() {
81
- modalOpen = false;
82
- }
83
-
84
- function coerce(f: FieldDefinition, raw: unknown): unknown {
85
- if (f.type === 'number') return raw === '' || raw == null ? null : Number(raw);
86
- return raw;
87
- }
88
-
89
- function saveItem() {
90
- const fd = new FormData();
91
- for (const f of subFields) fd.set(f.attribute, String(draft[f.attribute] ?? ''));
92
- const errs = validateAll(subFields, fd, strings);
93
- if (Object.keys(errs).length > 0) {
94
- draftErrors = errs;
95
- return;
96
- }
97
- const item = Object.fromEntries(
98
- subFields.map((f) => [f.attribute, coerce(f, draft[f.attribute])])
99
- );
100
- record[field.attribute] =
101
- editingIndex == null
102
- ? [...items, item]
103
- : items.map((existing, i) => (i === editingIndex ? item : existing));
104
- modalOpen = false;
105
- }
106
-
107
- function removeItem(index: number) {
108
- record[field.attribute] = items.filter((_, i) => i !== index);
109
- }
110
-
111
- function itemLabel(item: Record<string, unknown>): string {
112
- return field.itemLabel ? field.itemLabel(item) : defaultItemLabel(subFields, item);
113
- }
114
- </script>
115
-
116
- <div class="flex flex-col gap-2">
117
- {#if !readonly}
118
- <input type="hidden" name={field.attribute} value={JSON.stringify(items)} />
119
- {/if}
120
-
121
- {#if items.length === 0}
122
- <p class="text-sm text-base-content/50">{strings.noItems}</p>
123
- {:else}
124
- <ul class="flex flex-col gap-1">
125
- {#each items as item, i (i)}
126
- <li
127
- class="flex items-center justify-between gap-2 rounded-box border border-base-300 px-3 py-2 text-sm"
128
- >
129
- {#if readonly}
130
- <span>{itemLabel(item)}</span>
131
- {:else}
132
- <button
133
- type="button"
134
- class="flex-1 text-left hover:underline"
135
- onclick={() => openEditModal(i)}
136
- >
137
- {itemLabel(item)}
138
- </button>
139
- <Button
140
- variant="ghost"
141
- class="btn-xs btn-circle"
142
- aria-label={strings.remove}
143
- onclick={() => removeItem(i)}
144
- >
145
- ✕
146
- </Button>
147
- {/if}
148
- </li>
149
- {/each}
150
- </ul>
151
- {/if}
152
-
153
- {#if !readonly}
154
- <Button variant="outline" class="btn-sm self-start" onclick={openCreateModal}>
155
- + {strings.add}
156
- </Button>
157
- {/if}
158
- </div>
159
-
160
- {#if modalOpen}
161
- <Modal title={fieldLabel(field)} onClose={closeModal}>
162
- <div class="flex flex-col gap-4">
163
- {#each subGroups as group, i (group.title ?? `_ungrouped_${i}`)}
164
- {#if group.title}
165
- <fieldset class="fieldset border border-base-300 rounded-box p-4">
166
- <legend class="fieldset-legend px-2">{group.title}</legend>
167
- <div class="flex flex-col gap-4">
168
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
169
- {#if row.length > 1}
170
- <div class="flex flex-col gap-4 md:flex-row">
171
- {#each row as f (f.attribute)}
172
- <Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
173
- {/each}
174
- </div>
175
- {:else}
176
- <Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
177
- {/if}
178
- {/each}
179
- </div>
180
- </fieldset>
181
- {:else}
182
- {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
183
- {#if row.length > 1}
184
- <div class="flex flex-col gap-4 md:flex-row">
185
- {#each row as f (f.attribute)}
186
- <Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
187
- {/each}
188
- </div>
189
- {:else}
190
- <Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
191
- {/if}
192
- {/each}
193
- {/if}
194
- {/each}
195
- <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
196
- <Button variant="ghost" onclick={closeModal}>{strings.cancel}</Button>
197
- <Button variant="primary" onclick={saveItem}>
198
- {editingIndex == null ? strings.add : strings.edit}
199
- </Button>
200
- </div>
201
- </div>
202
- </Modal>
203
- {/if}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import { untrack } from 'svelte';
3
+ import Field from './Field.svelte';
4
+ import Button from '../form/Button.svelte';
5
+ import Modal from '../Modal.svelte';
6
+ import { emptyRecord, seedField, defaultItemLabel } from './utils/embedded.js';
7
+ import { fieldLabel } from './utils/misc.js';
8
+ import { validateAll } from './utils/validation.js';
9
+ import { groupFields } from './utils/grouping.js';
10
+ import type { FieldDefinition } from '../../types/crud/fields.js';
11
+ import { getStrings } from '../../i18n/context.js';
12
+
13
+ const strings = getStrings();
14
+
15
+ let {
16
+ field,
17
+ record = $bindable({} as Record<string, unknown>),
18
+ readonly = false
19
+ }: {
20
+ field: FieldDefinition<T>;
21
+ /** The record that owns this embedded field (the form record for a
22
+ * top-level embedded field, or the outer item's draft for one nested
23
+ * inside another embedded field) — passed through to each sub-field's
24
+ * `hidden`/`disabled`/`required`/`dependentOptions` as their `parent`
25
+ * argument, and to `field.revalidate` as its own parent argument. */
26
+ record?: Record<string, unknown>;
27
+ readonly?: boolean;
28
+ } = $props();
29
+
30
+ const subFields = $derived(field.fields ?? []);
31
+ const subGroups = $derived(groupFields(subFields));
32
+ const items = $derived((record[field.attribute] as Record<string, unknown>[] | undefined) ?? []);
33
+
34
+ // Re-derives the embedded list right after `field.dependsOn` changes value
35
+ // on the parent record — e.g. dropping items that no longer apply once a
36
+ // sibling "owner kind" select flips — instead of leaving them stale until
37
+ // the user happens to touch this field again. Scoped to exactly that one
38
+ // parent attribute: reading it is this effect's only tracked dependency,
39
+ // and everything else (including its own write to `record[field.attribute]`)
40
+ // runs inside `untrack` so the effect never re-triggers itself.
41
+ $effect(() => {
42
+ if (!field.dependsOn || !field.revalidate) return;
43
+ // Read (only) to register as this effect's sole tracked dependency —
44
+ // everything that actually acts on it happens inside untrack() below,
45
+ // so the effect reruns exactly when this one value changes and never
46
+ // because of its own write to record[field.attribute].
47
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
48
+ const dependencyValue = record[field.dependsOn];
49
+ untrack(() => {
50
+ const current = (record[field.attribute] as Record<string, unknown>[] | undefined) ?? [];
51
+ const next = field.revalidate!(current, record);
52
+ if (next !== current) record[field.attribute] = next;
53
+ });
54
+ });
55
+
56
+ let modalOpen = $state(false);
57
+ // null while adding a new item; the item's index while editing an existing
58
+ // one, so saveItem knows whether to append or replace in place.
59
+ let editingIndex = $state<number | null>(null);
60
+ let draft = $state<Record<string, unknown>>({});
61
+ let draftErrors = $state<Record<string, string>>({});
62
+
63
+ function openCreateModal() {
64
+ editingIndex = null;
65
+ draft = emptyRecord(subFields);
66
+ draftErrors = {};
67
+ modalOpen = true;
68
+ }
69
+
70
+ function openEditModal(index: number) {
71
+ const item = items[index];
72
+ editingIndex = index;
73
+ draft = Object.fromEntries(
74
+ subFields.map((f) => [f.attribute, seedField(f, f.seed ? f.seed(item) : item[f.attribute])])
75
+ );
76
+ draftErrors = {};
77
+ modalOpen = true;
78
+ }
79
+
80
+ function closeModal() {
81
+ modalOpen = false;
82
+ }
83
+
84
+ function coerce(f: FieldDefinition, raw: unknown): unknown {
85
+ if (f.type === 'number') return raw === '' || raw == null ? null : Number(raw);
86
+ return raw;
87
+ }
88
+
89
+ function saveItem() {
90
+ const fd = new FormData();
91
+ for (const f of subFields) fd.set(f.attribute, String(draft[f.attribute] ?? ''));
92
+ const errs = validateAll(subFields, fd, strings);
93
+ if (Object.keys(errs).length > 0) {
94
+ draftErrors = errs;
95
+ return;
96
+ }
97
+ const item = Object.fromEntries(
98
+ subFields.map((f) => [f.attribute, coerce(f, draft[f.attribute])])
99
+ );
100
+ record[field.attribute] =
101
+ editingIndex == null
102
+ ? [...items, item]
103
+ : items.map((existing, i) => (i === editingIndex ? item : existing));
104
+ modalOpen = false;
105
+ }
106
+
107
+ function removeItem(index: number) {
108
+ record[field.attribute] = items.filter((_, i) => i !== index);
109
+ }
110
+
111
+ function itemLabel(item: Record<string, unknown>): string {
112
+ return field.itemLabel ? field.itemLabel(item) : defaultItemLabel(subFields, item);
113
+ }
114
+ </script>
115
+
116
+ <div class="flex flex-col gap-2">
117
+ {#if !readonly}
118
+ <input type="hidden" name={field.attribute} value={JSON.stringify(items)} />
119
+ {/if}
120
+
121
+ {#if items.length === 0}
122
+ <p class="text-sm text-base-content/50">{strings.noItems}</p>
123
+ {:else}
124
+ <ul class="flex flex-col gap-1">
125
+ {#each items as item, i (i)}
126
+ <li
127
+ class="flex items-center justify-between gap-2 rounded-box border border-base-300 px-3 py-2 text-sm"
128
+ >
129
+ {#if readonly}
130
+ <span>{itemLabel(item)}</span>
131
+ {:else}
132
+ <button
133
+ type="button"
134
+ class="flex-1 text-left hover:underline"
135
+ onclick={() => openEditModal(i)}
136
+ >
137
+ {itemLabel(item)}
138
+ </button>
139
+ <Button
140
+ variant="ghost"
141
+ class="btn-xs btn-circle"
142
+ aria-label={strings.remove}
143
+ onclick={() => removeItem(i)}
144
+ >
145
+ ✕
146
+ </Button>
147
+ {/if}
148
+ </li>
149
+ {/each}
150
+ </ul>
151
+ {/if}
152
+
153
+ {#if !readonly}
154
+ <Button variant="outline" class="btn-sm self-start" onclick={openCreateModal}>
155
+ + {strings.add}
156
+ </Button>
157
+ {/if}
158
+ </div>
159
+
160
+ {#if modalOpen}
161
+ <Modal title={fieldLabel(field)} onClose={closeModal}>
162
+ <div class="flex flex-col gap-4">
163
+ {#each subGroups as group, i (group.title ?? `_ungrouped_${i}`)}
164
+ {#if group.title}
165
+ <fieldset class="fieldset border border-base-300 rounded-box p-4">
166
+ <legend class="fieldset-legend px-2">{group.title}</legend>
167
+ <div class="flex flex-col gap-4">
168
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
169
+ {#if row.length > 1}
170
+ <div class="flex flex-col gap-4 md:flex-row">
171
+ {#each row as f (f.attribute)}
172
+ <Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
173
+ {/each}
174
+ </div>
175
+ {:else}
176
+ <Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
177
+ {/if}
178
+ {/each}
179
+ </div>
180
+ </fieldset>
181
+ {:else}
182
+ {#each group.rows as row (row.map((f) => f.attribute).join('|'))}
183
+ {#if row.length > 1}
184
+ <div class="flex flex-col gap-4 md:flex-row">
185
+ {#each row as f (f.attribute)}
186
+ <Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
187
+ {/each}
188
+ </div>
189
+ {:else}
190
+ <Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
191
+ {/if}
192
+ {/each}
193
+ {/if}
194
+ {/each}
195
+ <div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
196
+ <Button variant="ghost" onclick={closeModal}>{strings.cancel}</Button>
197
+ <Button variant="primary" onclick={saveItem}>
198
+ {editingIndex == null ? strings.add : strings.edit}
199
+ </Button>
200
+ </div>
201
+ </div>
202
+ </Modal>
203
+ {/if}
@@ -1,122 +1,122 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import Button from '../form/Button.svelte';
3
- import Label from '../form/Label.svelte';
4
- import EmbeddedField from './EmbeddedField.svelte';
5
- import BooleanField from './fields/BooleanField.svelte';
6
- import FileField from './fields/FileField.svelte';
7
- import SelectField from './fields/SelectField.svelte';
8
- import MultiSelectField from './fields/MultiSelectField.svelte';
9
- import TreeField from './fields/TreeField.svelte';
10
- import DatetimeField from './fields/DatetimeField.svelte';
11
- import TextareaField from './fields/TextareaField.svelte';
12
- import DefaultField from './fields/DefaultField.svelte';
13
- import { fieldLabel } from './utils/misc.js';
14
- import type { FieldDefinition } from '../../types/crud/fields.js';
15
-
16
- let {
17
- field,
18
- record = $bindable({} as Record<string, unknown>),
19
- parentRecord = undefined,
20
- error = '',
21
- readonly = false,
22
- class: className = ''
23
- }: {
24
- field: FieldDefinition<T>;
25
- record?: Record<string, unknown>;
26
- /** Set only when this Field renders a sub-field of an `embedded` field
27
- * — the outer form's own record, passed through to `hidden`/`disabled`/
28
- * `required`/`dependentOptions` as their second argument. */
29
- parentRecord?: Record<string, unknown>;
30
- error?: string;
31
- readonly?: boolean;
32
- class?: string;
33
- } = $props();
34
-
35
- const labelText = $derived(fieldLabel(field));
36
- const fieldRequired = $derived(
37
- typeof field.required === 'function' ? field.required(record, parentRecord) : !!field.required
38
- );
39
- const fieldHidden = $derived(
40
- typeof field.hidden === 'function' ? field.hidden(record, parentRecord) : !!field.hidden
41
- );
42
- const fieldActions = $derived(
43
- (field.actions ?? []).filter((action) => action.condition?.(record) ?? true)
44
- );
45
- </script>
46
-
47
- <!-- Shared by every field type below, always rendered in the same spot
48
- (right after an optional type-specific lead-in, e.g. FileField's avatar
49
- preview) so each type-specific component just brackets its own markup
50
- with these two instead of re-declaring the label/error chrome itself. -->
51
- {#snippet labelRow()}
52
- <div class="flex items-center justify-between gap-2">
53
- <Label
54
- text={labelText}
55
- for={field.attribute}
56
- capitalize={true}
57
- required={fieldRequired && !readonly}
58
- />
59
- {#if !readonly && fieldActions.length > 0}
60
- <div class="flex items-center gap-1">
61
- {#each fieldActions as action (action.label)}
62
- <Button
63
- type="button"
64
- variant="ghost"
65
- class={['btn-xs', action.class]}
66
- title={action.label}
67
- onclick={() =>
68
- action.run(record[field.attribute], record, (attribute, value) => {
69
- record[attribute] = value;
70
- })}
71
- >
72
- {#if action.icon}
73
- {@const Icon = action.icon}
74
- <Icon class="size-4" />
75
- {/if}
76
- {action.label}
77
- </Button>
78
- {/each}
79
- </div>
80
- {/if}
81
- </div>
82
- {/snippet}
83
-
84
- {#snippet errorRow()}
85
- {#if error}
86
- <span class="text-error text-xs">{error}</span>
87
- {/if}
88
- {/snippet}
89
-
90
- {#if !fieldHidden}
91
- <div class="flex flex-col gap-1 {className}">
92
- {#if field.type === 'boolean'}
93
- <BooleanField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
94
- {:else if field.type === 'file'}
95
- <FileField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
96
- {:else if field.type === 'select'}
97
- <SelectField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
98
- {:else if field.type === 'datetime'}
99
- <DatetimeField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
100
- {:else if field.type === 'textarea'}
101
- <TextareaField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
102
- {:else if field.type === 'embedded'}
103
- {@render labelRow()}
104
- <EmbeddedField {field} bind:record {readonly} />
105
- {@render errorRow()}
106
- {:else if field.type === 'multiselect'}
107
- <MultiSelectField
108
- {field}
109
- bind:record
110
- {parentRecord}
111
- {error}
112
- {readonly}
113
- {labelRow}
114
- {errorRow}
115
- />
116
- {:else if field.type === 'tree'}
117
- <TreeField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
118
- {:else}
119
- <DefaultField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
120
- {/if}
121
- </div>
122
- {/if}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import Button from '../form/Button.svelte';
3
+ import Label from '../form/Label.svelte';
4
+ import EmbeddedField from './EmbeddedField.svelte';
5
+ import BooleanField from './fields/BooleanField.svelte';
6
+ import FileField from './fields/FileField.svelte';
7
+ import SelectField from './fields/SelectField.svelte';
8
+ import MultiSelectField from './fields/MultiSelectField.svelte';
9
+ import TreeField from './fields/TreeField.svelte';
10
+ import DatetimeField from './fields/DatetimeField.svelte';
11
+ import TextareaField from './fields/TextareaField.svelte';
12
+ import DefaultField from './fields/DefaultField.svelte';
13
+ import { fieldLabel } from './utils/misc.js';
14
+ import type { FieldDefinition } from '../../types/crud/fields.js';
15
+
16
+ let {
17
+ field,
18
+ record = $bindable({} as Record<string, unknown>),
19
+ parentRecord = undefined,
20
+ error = '',
21
+ readonly = false,
22
+ class: className = ''
23
+ }: {
24
+ field: FieldDefinition<T>;
25
+ record?: Record<string, unknown>;
26
+ /** Set only when this Field renders a sub-field of an `embedded` field
27
+ * — the outer form's own record, passed through to `hidden`/`disabled`/
28
+ * `required`/`dependentOptions` as their second argument. */
29
+ parentRecord?: Record<string, unknown>;
30
+ error?: string;
31
+ readonly?: boolean;
32
+ class?: string;
33
+ } = $props();
34
+
35
+ const labelText = $derived(fieldLabel(field));
36
+ const fieldRequired = $derived(
37
+ typeof field.required === 'function' ? field.required(record, parentRecord) : !!field.required
38
+ );
39
+ const fieldHidden = $derived(
40
+ typeof field.hidden === 'function' ? field.hidden(record, parentRecord) : !!field.hidden
41
+ );
42
+ const fieldActions = $derived(
43
+ (field.actions ?? []).filter((action) => action.condition?.(record) ?? true)
44
+ );
45
+ </script>
46
+
47
+ <!-- Shared by every field type below, always rendered in the same spot
48
+ (right after an optional type-specific lead-in, e.g. FileField's avatar
49
+ preview) so each type-specific component just brackets its own markup
50
+ with these two instead of re-declaring the label/error chrome itself. -->
51
+ {#snippet labelRow()}
52
+ <div class="flex items-center justify-between gap-2">
53
+ <Label
54
+ text={labelText}
55
+ for={field.attribute}
56
+ capitalize={true}
57
+ required={fieldRequired && !readonly}
58
+ />
59
+ {#if !readonly && fieldActions.length > 0}
60
+ <div class="flex items-center gap-1">
61
+ {#each fieldActions as action (action.label)}
62
+ <Button
63
+ type="button"
64
+ variant="ghost"
65
+ class={['btn-xs', action.class]}
66
+ title={action.label}
67
+ onclick={() =>
68
+ action.run(record[field.attribute], record, (attribute, value) => {
69
+ record[attribute] = value;
70
+ })}
71
+ >
72
+ {#if action.icon}
73
+ {@const Icon = action.icon}
74
+ <Icon class="size-4" />
75
+ {/if}
76
+ {action.label}
77
+ </Button>
78
+ {/each}
79
+ </div>
80
+ {/if}
81
+ </div>
82
+ {/snippet}
83
+
84
+ {#snippet errorRow()}
85
+ {#if error}
86
+ <span class="text-error text-xs">{error}</span>
87
+ {/if}
88
+ {/snippet}
89
+
90
+ {#if !fieldHidden}
91
+ <div class="flex flex-col gap-1 {className}">
92
+ {#if field.type === 'boolean'}
93
+ <BooleanField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
94
+ {:else if field.type === 'file'}
95
+ <FileField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
96
+ {:else if field.type === 'select'}
97
+ <SelectField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
98
+ {:else if field.type === 'datetime'}
99
+ <DatetimeField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
100
+ {:else if field.type === 'textarea'}
101
+ <TextareaField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
102
+ {:else if field.type === 'embedded'}
103
+ {@render labelRow()}
104
+ <EmbeddedField {field} bind:record {readonly} />
105
+ {@render errorRow()}
106
+ {:else if field.type === 'multiselect'}
107
+ <MultiSelectField
108
+ {field}
109
+ bind:record
110
+ {parentRecord}
111
+ {error}
112
+ {readonly}
113
+ {labelRow}
114
+ {errorRow}
115
+ />
116
+ {:else if field.type === 'tree'}
117
+ <TreeField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
118
+ {:else}
119
+ <DefaultField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
120
+ {/if}
121
+ </div>
122
+ {/if}