runeforge 0.0.53 → 0.0.55

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 (78) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1342 -1222
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +22 -22
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +175 -175
  8. package/dist/components/crud/Field.svelte +376 -376
  9. package/dist/components/crud/GenericCRUD.svelte +426 -435
  10. package/dist/components/crud/GenericCRUD.svelte.d.ts +6 -9
  11. package/dist/components/crud/SearchInput.svelte +53 -53
  12. package/dist/components/crud/columns/Avatar.svelte +15 -15
  13. package/dist/components/crud/columns/Icon.svelte +8 -8
  14. package/dist/components/crud/utils/reorder.d.ts +4 -0
  15. package/dist/components/crud/utils/reorder.js +13 -0
  16. package/dist/components/crud/views/Create.svelte +232 -232
  17. package/dist/components/crud/views/Read.svelte +124 -124
  18. package/dist/components/crud/views/Update.svelte +208 -208
  19. package/dist/components/crud/views/list/List.svelte +291 -0
  20. package/dist/components/crud/views/{List.svelte.d.ts → list/List.svelte.d.ts} +4 -14
  21. package/dist/components/crud/views/list/Modals.svelte +56 -0
  22. package/dist/components/crud/views/list/Modals.svelte.d.ts +36 -0
  23. package/dist/components/crud/views/list/Table.svelte +176 -0
  24. package/dist/components/crud/views/list/Table.svelte.d.ts +59 -0
  25. package/dist/components/crud/views/list/Toolbar.svelte +341 -0
  26. package/dist/components/crud/views/list/Toolbar.svelte.d.ts +46 -0
  27. package/dist/components/form/Button.svelte +27 -27
  28. package/dist/components/form/Label.svelte +37 -37
  29. package/dist/components/form/MultiSelect.svelte +248 -248
  30. package/dist/components/form/PasswordInput.svelte +68 -68
  31. package/dist/components/form/Required.svelte +1 -1
  32. package/dist/components/form/Select.svelte +209 -209
  33. package/dist/components/form/Tree.svelte +62 -62
  34. package/dist/components/form/TreeNode.svelte +66 -66
  35. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  36. package/dist/components/table/ColumnFilter.svelte +168 -168
  37. package/dist/components/table/PaginatedTable.svelte +536 -215
  38. package/dist/components/table/PaginatedTable.svelte.d.ts +11 -2
  39. package/dist/components/table/Paginator.svelte +113 -113
  40. package/dist/components/table/SortHeader.svelte +43 -43
  41. package/dist/components/table/TableBody.svelte +150 -70
  42. package/dist/components/table/TableBody.svelte.d.ts +14 -1
  43. package/dist/components/table/TableHeader.svelte +88 -83
  44. package/dist/components/table/TableHeader.svelte.d.ts +1 -0
  45. package/dist/components/table/sortable.d.ts +27 -0
  46. package/dist/components/table/sortable.js +55 -0
  47. package/dist/components/table/utils.d.ts +28 -1
  48. package/dist/components/table/utils.js +75 -0
  49. package/dist/i18n/en.js +2 -0
  50. package/dist/i18n/es.js +2 -0
  51. package/dist/i18n/types.d.ts +2 -0
  52. package/dist/icons/defaults/Clear.svelte +6 -6
  53. package/dist/icons/defaults/Create.svelte +6 -6
  54. package/dist/icons/defaults/Delete.svelte +6 -6
  55. package/dist/icons/defaults/Download.svelte +7 -7
  56. package/dist/icons/defaults/Edit.svelte +7 -7
  57. package/dist/icons/defaults/Filter.svelte +6 -6
  58. package/dist/icons/defaults/FilterActive.svelte +6 -6
  59. package/dist/icons/defaults/Folder.svelte +6 -6
  60. package/dist/icons/defaults/Grip.svelte +7 -0
  61. package/dist/icons/defaults/Grip.svelte.d.ts +7 -0
  62. package/dist/icons/defaults/Home.svelte +6 -6
  63. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  64. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  65. package/dist/icons/defaults/SortAsc.svelte +6 -6
  66. package/dist/icons/defaults/SortDesc.svelte +6 -6
  67. package/dist/icons/defaults/SortNone.svelte +6 -6
  68. package/dist/icons/defaults/View.svelte +7 -7
  69. package/dist/icons/sets/bootstrap.js +2 -1
  70. package/dist/icons/sets/default.js +2 -0
  71. package/dist/icons/types.d.ts +3 -0
  72. package/dist/index.d.ts +5 -4
  73. package/dist/index.js +3 -2
  74. package/dist/types/attribute.d.ts +9 -0
  75. package/dist/types/crud.d.ts +72 -1
  76. package/dist/types/table.d.ts +56 -0
  77. package/package.json +3 -1
  78. package/dist/components/crud/views/List.svelte +0 -584
@@ -1,376 +1,376 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import { onMount } from 'svelte';
3
- import Avatar from '../Avatar.svelte';
4
- import Label from '../form/Label.svelte';
5
- import Select from '../form/Select.svelte';
6
- import MultiSelect from '../form/MultiSelect.svelte';
7
- import Tree from '../form/Tree.svelte';
8
- import EmbeddedField from './EmbeddedField.svelte';
9
- import { fieldLabel, initials } from './utils/misc.js';
10
- import type { FieldDefinition } from '../../types/crud.js';
11
- import { getStrings } from '../../i18n/context.js';
12
-
13
- const strings = getStrings();
14
-
15
- onMount(() => {
16
- import('cally');
17
- });
18
-
19
- let {
20
- field,
21
- record = $bindable({} as Record<string, unknown>),
22
- error = '',
23
- readonly = false,
24
- class: className = ''
25
- }: {
26
- field: FieldDefinition<T>;
27
- record?: Record<string, unknown>;
28
- error?: string;
29
- readonly?: boolean;
30
- class?: string;
31
- } = $props();
32
-
33
- const datePopId = $props.id();
34
- const dateAnchorName = `--date-anchor-${datePopId}`;
35
- let datePopoverEl: HTMLElement | undefined = $state();
36
- let calendarDateEl: (HTMLElement & { value: string }) | undefined = $state();
37
-
38
- const name = $derived(readonly ? undefined : field.attribute);
39
- const labelText = $derived(fieldLabel(field));
40
- let filePreview = $state<string | null>(null);
41
-
42
- function onFileChange(e: Event & { currentTarget: HTMLInputElement }) {
43
- const file = e.currentTarget.files?.[0];
44
- if (filePreview) URL.revokeObjectURL(filePreview);
45
- filePreview = file && file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
46
- }
47
-
48
- $effect(() => () => {
49
- if (filePreview) URL.revokeObjectURL(filePreview);
50
- });
51
-
52
- const saved = $derived(record[field.attribute]);
53
- const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
54
- const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
55
- const displayValue = $derived(saved == null ? '' : String(saved));
56
- // Only for readonly branches with no other resolution mechanism (select/
57
- // multiselect/tree already resolve their own label from `options`, and
58
- // some of their formatters return HTML meant for a table cell, not plain
59
- // input text). Falls back to `displayValue` so a value with no formatter
60
- // still renders instead of going blank.
61
- const formattedValue = $derived(
62
- readonly && field.formatter ? field.formatter(saved, record) : displayValue
63
- );
64
- const selectOptions = $derived(
65
- field.dependentOptions ? field.dependentOptions(record) : (field.options ?? [])
66
- );
67
- const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
68
- const fieldRequired = $derived(
69
- typeof field.required === 'function' ? field.required(record) : !!field.required
70
- );
71
- const fieldHidden = $derived(
72
- typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden
73
- );
74
- const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
75
-
76
- // cally's `change` event doesn't bubble, so the usual `onchange={...}` prop
77
- // never fires — Svelte 5 delegates events like `change` to a listener on
78
- // a shared ancestor, which only ever sees events that bubble up to it.
79
- // Attaching directly on the element (same fix ColumnFilter's date-range
80
- // picker already uses) sidesteps delegation entirely.
81
- $effect(() => {
82
- if (!calendarDateEl) return;
83
- function handler() {
84
- if (fieldDisabled) return;
85
- setDateTime(calendarDateEl!.value, timePart);
86
- datePopoverEl?.hidePopover();
87
- }
88
- calendarDateEl.addEventListener('change', handler);
89
- return () => calendarDateEl?.removeEventListener('change', handler);
90
- });
91
-
92
- function pad(n: number): string {
93
- return String(n).padStart(2, '0');
94
- }
95
-
96
- // `record[field.attribute]` may be empty, a bare "YYYY-MM-DD" (freshly
97
- // picked, before any time is set) or a full ISO datetime (loaded from an
98
- // existing instance) — parsed once here so the date button, the time
99
- // input and cally's own `value` all derive from the same local-time
100
- // breakdown instead of each re-parsing the raw string their own way.
101
- const parsedDateTime = $derived.by(() => {
102
- const raw = record[field.attribute];
103
- if (raw == null || raw === '') return null;
104
- const d = new Date(String(raw));
105
- return isNaN(d.getTime()) ? null : d;
106
- });
107
- const datePart = $derived(
108
- parsedDateTime
109
- ? `${parsedDateTime.getFullYear()}-${pad(parsedDateTime.getMonth() + 1)}-${pad(parsedDateTime.getDate())}`
110
- : ''
111
- );
112
- const timePart = $derived(
113
- parsedDateTime ? `${pad(parsedDateTime.getHours())}:${pad(parsedDateTime.getMinutes())}` : ''
114
- );
115
- const formattedDatePart = $derived(
116
- parsedDateTime
117
- ? `${pad(parsedDateTime.getDate())}/${pad(parsedDateTime.getMonth() + 1)}/${parsedDateTime.getFullYear()}`
118
- : ''
119
- );
120
-
121
- function setDateTime(nextDatePart: string, nextTimePart: string) {
122
- record[field.attribute] = nextDatePart ? `${nextDatePart}T${nextTimePart || '00:00'}` : '';
123
- }
124
-
125
- $effect(() => {
126
- if (!field.dependentOptions || isMultiValued) return;
127
- const current = record[field.attribute];
128
- if (current && !selectOptions.some((o) => o.value === String(current))) {
129
- record[field.attribute] = '';
130
- }
131
- });
132
-
133
- $effect(() => {
134
- if (!field.dependentOptions || !isMultiValued) return;
135
- const current = record[field.attribute];
136
- if (!Array.isArray(current)) return;
137
- const validValues = new Set(selectOptions.map((o) => o.value));
138
- const pruned = current.filter((v) => validValues.has(String(v)));
139
- if (pruned.length !== current.length) {
140
- record[field.attribute] = pruned;
141
- }
142
- });
143
- </script>
144
-
145
- {#if !fieldHidden}
146
- <div class="flex flex-col gap-1 {className}">
147
- {#if field.type === 'file'}
148
- <div class="flex justify-center">
149
- <Avatar
150
- src={preview}
151
- text={avatarInitials}
152
- alt={labelText}
153
- class="w-20 rounded-full"
154
- textClass="text-xl"
155
- />
156
- </div>
157
- {/if}
158
-
159
- <Label
160
- text={labelText}
161
- for={field.attribute}
162
- capitalize={true}
163
- required={fieldRequired && !readonly}
164
- />
165
-
166
- {#if field.type === 'boolean'}
167
- <input
168
- type="checkbox"
169
- id={field.attribute}
170
- {name}
171
- class="toggle toggle-primary"
172
- checked={!!saved}
173
- disabled={readonly || fieldDisabled}
174
- onchange={(e) => {
175
- record[field.attribute] = (e.currentTarget as HTMLInputElement).checked;
176
- }}
177
- />
178
- {:else if field.type === 'file'}
179
- {#if !readonly}
180
- <input
181
- type="file"
182
- id={field.attribute}
183
- {name}
184
- class="file-input file-input-bordered w-full"
185
- class:file-input-error={!!error}
186
- disabled={fieldDisabled}
187
- onchange={onFileChange}
188
- />
189
- {/if}
190
- <!-- read-only file value is shown as the avatar above -->
191
- {:else if field.type === 'select'}
192
- {#if readonly}
193
- <input
194
- type="text"
195
- id={field.attribute}
196
- class="input input-bordered w-full"
197
- value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
198
- disabled
199
- />
200
- {:else}
201
- <Select
202
- id={field.attribute}
203
- name={field.attribute}
204
- bind:value={record[field.attribute] as string}
205
- options={selectOptions}
206
- search={field.search}
207
- placeholder={field.placeholder}
208
- disabled={fieldDisabled}
209
- {error}
210
- />
211
- {/if}
212
- {:else if field.type === 'datetime'}
213
- {#if readonly}
214
- <input
215
- type="text"
216
- id={field.attribute}
217
- class="input input-bordered w-full"
218
- value={formattedValue}
219
- disabled
220
- />
221
- {:else}
222
- <input
223
- type="hidden"
224
- {name}
225
- value={String(record[field.attribute] ?? '')}
226
- disabled={fieldDisabled}
227
- />
228
- <div class="flex gap-2">
229
- <button
230
- type="button"
231
- class="input input-bordered w-full text-left font-normal"
232
- class:opacity-40={!record[field.attribute]}
233
- disabled={fieldDisabled}
234
- popovertarget={datePopId}
235
- style="anchor-name:{dateAnchorName}"
236
- >
237
- {record[field.attribute] ? formattedDatePart : (field.placeholder ?? '')}
238
- </button>
239
- <input
240
- type="time"
241
- class="input input-bordered w-32 shrink-0"
242
- value={timePart}
243
- disabled={fieldDisabled || !record[field.attribute]}
244
- onchange={(e) => setDateTime(datePart, (e.currentTarget as HTMLInputElement).value)}
245
- />
246
- </div>
247
- <div
248
- popover="auto"
249
- id={datePopId}
250
- bind:this={datePopoverEl}
251
- style="position-anchor:{dateAnchorName}; position-try-fallbacks:flip-block;"
252
- class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
253
- >
254
- <calendar-date class="cally" bind:this={calendarDateEl} value={datePart}>
255
- <svg
256
- aria-label={strings.previous}
257
- class="fill-current size-4"
258
- {...{ slot: 'previous' }}
259
- xmlns="http://www.w3.org/2000/svg"
260
- viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg
261
- >
262
- <svg
263
- aria-label={strings.next}
264
- class="fill-current size-4"
265
- {...{ slot: 'next' }}
266
- xmlns="http://www.w3.org/2000/svg"
267
- viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg
268
- >
269
- <calendar-month></calendar-month>
270
- </calendar-date>
271
- </div>
272
- {/if}
273
- {:else if field.type === 'textarea'}
274
- {#if readonly}
275
- <textarea
276
- id={field.attribute}
277
- rows={field.rows}
278
- class="textarea textarea-bordered bg-base-100 w-full"
279
- value={formattedValue}
280
- disabled
281
- ></textarea>
282
- {:else}
283
- <textarea
284
- id={field.attribute}
285
- {name}
286
- rows={field.rows}
287
- placeholder={field.placeholder ?? ''}
288
- bind:value={record[field.attribute]}
289
- class="textarea textarea-bordered bg-base-100 w-full"
290
- class:textarea-error={!!error}
291
- disabled={fieldDisabled}
292
- ></textarea>
293
- {/if}
294
- {:else if field.type === 'embedded'}
295
- <EmbeddedField {field} bind:record {readonly} />
296
- {:else if field.type === 'multiselect'}
297
- {#if readonly}
298
- <input
299
- type="text"
300
- id={field.attribute}
301
- class="input input-bordered w-full"
302
- value={(Array.isArray(saved) ? saved : [])
303
- .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
304
- .join(', ')}
305
- disabled
306
- />
307
- {:else}
308
- <MultiSelect
309
- id={field.attribute}
310
- name={field.attribute}
311
- bind:value={record[field.attribute] as string[]}
312
- options={selectOptions}
313
- search={field.search}
314
- placeholder={field.placeholder}
315
- disabled={fieldDisabled}
316
- {error}
317
- />
318
- {/if}
319
- {:else if field.type === 'tree'}
320
- {#if readonly}
321
- <input
322
- type="text"
323
- id={field.attribute}
324
- class="input input-bordered w-full"
325
- value={(Array.isArray(saved) ? saved : [])
326
- .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
327
- .join(', ')}
328
- disabled
329
- />
330
- {:else}
331
- <Tree
332
- name={field.attribute}
333
- bind:value={record[field.attribute] as string[]}
334
- options={selectOptions}
335
- disabled={fieldDisabled}
336
- defaultExpanded={field.defaultExpanded ?? true}
337
- />
338
- {/if}
339
- {:else if readonly}
340
- {#if field.formatter}
341
- <div
342
- id={field.attribute}
343
- class="input input-bordered w-full bg-base-200 border-base-200 text-base-content/40 shadow-none cursor-not-allowed"
344
- >
345
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
346
- {@html formattedValue}
347
- </div>
348
- {:else}
349
- <input
350
- type={field.type ?? 'text'}
351
- id={field.attribute}
352
- class="input input-bordered w-full"
353
- value={formattedValue}
354
- disabled
355
- />
356
- {/if}
357
- {:else}
358
- <input
359
- type={field.type ?? 'text'}
360
- id={field.attribute}
361
- {name}
362
- placeholder={field.placeholder ?? ''}
363
- bind:value={record[field.attribute]}
364
- autocomplete={field.autocomplete}
365
- step={field.type === 'number' ? 'any' : undefined}
366
- class="input input-bordered w-full"
367
- class:input-error={!!error}
368
- disabled={fieldDisabled}
369
- />
370
- {/if}
371
-
372
- {#if error}
373
- <span class="text-error text-xs">{error}</span>
374
- {/if}
375
- </div>
376
- {/if}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import { onMount } from 'svelte';
3
+ import Avatar from '../Avatar.svelte';
4
+ import Label from '../form/Label.svelte';
5
+ import Select from '../form/Select.svelte';
6
+ import MultiSelect from '../form/MultiSelect.svelte';
7
+ import Tree from '../form/Tree.svelte';
8
+ import EmbeddedField from './EmbeddedField.svelte';
9
+ import { fieldLabel, initials } from './utils/misc.js';
10
+ import type { FieldDefinition } from '../../types/crud.js';
11
+ import { getStrings } from '../../i18n/context.js';
12
+
13
+ const strings = getStrings();
14
+
15
+ onMount(() => {
16
+ import('cally');
17
+ });
18
+
19
+ let {
20
+ field,
21
+ record = $bindable({} as Record<string, unknown>),
22
+ error = '',
23
+ readonly = false,
24
+ class: className = ''
25
+ }: {
26
+ field: FieldDefinition<T>;
27
+ record?: Record<string, unknown>;
28
+ error?: string;
29
+ readonly?: boolean;
30
+ class?: string;
31
+ } = $props();
32
+
33
+ const datePopId = $props.id();
34
+ const dateAnchorName = `--date-anchor-${datePopId}`;
35
+ let datePopoverEl: HTMLElement | undefined = $state();
36
+ let calendarDateEl: (HTMLElement & { value: string }) | undefined = $state();
37
+
38
+ const name = $derived(readonly ? undefined : field.attribute);
39
+ const labelText = $derived(fieldLabel(field));
40
+ let filePreview = $state<string | null>(null);
41
+
42
+ function onFileChange(e: Event & { currentTarget: HTMLInputElement }) {
43
+ const file = e.currentTarget.files?.[0];
44
+ if (filePreview) URL.revokeObjectURL(filePreview);
45
+ filePreview = file && file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
46
+ }
47
+
48
+ $effect(() => () => {
49
+ if (filePreview) URL.revokeObjectURL(filePreview);
50
+ });
51
+
52
+ const saved = $derived(record[field.attribute]);
53
+ const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
54
+ const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
55
+ const displayValue = $derived(saved == null ? '' : String(saved));
56
+ // Only for readonly branches with no other resolution mechanism (select/
57
+ // multiselect/tree already resolve their own label from `options`, and
58
+ // some of their formatters return HTML meant for a table cell, not plain
59
+ // input text). Falls back to `displayValue` so a value with no formatter
60
+ // still renders instead of going blank.
61
+ const formattedValue = $derived(
62
+ readonly && field.formatter ? field.formatter(saved, record) : displayValue
63
+ );
64
+ const selectOptions = $derived(
65
+ field.dependentOptions ? field.dependentOptions(record) : (field.options ?? [])
66
+ );
67
+ const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
68
+ const fieldRequired = $derived(
69
+ typeof field.required === 'function' ? field.required(record) : !!field.required
70
+ );
71
+ const fieldHidden = $derived(
72
+ typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden
73
+ );
74
+ const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
75
+
76
+ // cally's `change` event doesn't bubble, so the usual `onchange={...}` prop
77
+ // never fires — Svelte 5 delegates events like `change` to a listener on
78
+ // a shared ancestor, which only ever sees events that bubble up to it.
79
+ // Attaching directly on the element (same fix ColumnFilter's date-range
80
+ // picker already uses) sidesteps delegation entirely.
81
+ $effect(() => {
82
+ if (!calendarDateEl) return;
83
+ function handler() {
84
+ if (fieldDisabled) return;
85
+ setDateTime(calendarDateEl!.value, timePart);
86
+ datePopoverEl?.hidePopover();
87
+ }
88
+ calendarDateEl.addEventListener('change', handler);
89
+ return () => calendarDateEl?.removeEventListener('change', handler);
90
+ });
91
+
92
+ function pad(n: number): string {
93
+ return String(n).padStart(2, '0');
94
+ }
95
+
96
+ // `record[field.attribute]` may be empty, a bare "YYYY-MM-DD" (freshly
97
+ // picked, before any time is set) or a full ISO datetime (loaded from an
98
+ // existing instance) — parsed once here so the date button, the time
99
+ // input and cally's own `value` all derive from the same local-time
100
+ // breakdown instead of each re-parsing the raw string their own way.
101
+ const parsedDateTime = $derived.by(() => {
102
+ const raw = record[field.attribute];
103
+ if (raw == null || raw === '') return null;
104
+ const d = new Date(String(raw));
105
+ return isNaN(d.getTime()) ? null : d;
106
+ });
107
+ const datePart = $derived(
108
+ parsedDateTime
109
+ ? `${parsedDateTime.getFullYear()}-${pad(parsedDateTime.getMonth() + 1)}-${pad(parsedDateTime.getDate())}`
110
+ : ''
111
+ );
112
+ const timePart = $derived(
113
+ parsedDateTime ? `${pad(parsedDateTime.getHours())}:${pad(parsedDateTime.getMinutes())}` : ''
114
+ );
115
+ const formattedDatePart = $derived(
116
+ parsedDateTime
117
+ ? `${pad(parsedDateTime.getDate())}/${pad(parsedDateTime.getMonth() + 1)}/${parsedDateTime.getFullYear()}`
118
+ : ''
119
+ );
120
+
121
+ function setDateTime(nextDatePart: string, nextTimePart: string) {
122
+ record[field.attribute] = nextDatePart ? `${nextDatePart}T${nextTimePart || '00:00'}` : '';
123
+ }
124
+
125
+ $effect(() => {
126
+ if (!field.dependentOptions || isMultiValued) return;
127
+ const current = record[field.attribute];
128
+ if (current && !selectOptions.some((o) => o.value === String(current))) {
129
+ record[field.attribute] = '';
130
+ }
131
+ });
132
+
133
+ $effect(() => {
134
+ if (!field.dependentOptions || !isMultiValued) return;
135
+ const current = record[field.attribute];
136
+ if (!Array.isArray(current)) return;
137
+ const validValues = new Set(selectOptions.map((o) => o.value));
138
+ const pruned = current.filter((v) => validValues.has(String(v)));
139
+ if (pruned.length !== current.length) {
140
+ record[field.attribute] = pruned;
141
+ }
142
+ });
143
+ </script>
144
+
145
+ {#if !fieldHidden}
146
+ <div class="flex flex-col gap-1 {className}">
147
+ {#if field.type === 'file'}
148
+ <div class="flex justify-center">
149
+ <Avatar
150
+ src={preview}
151
+ text={avatarInitials}
152
+ alt={labelText}
153
+ class="w-20 rounded-full"
154
+ textClass="text-xl"
155
+ />
156
+ </div>
157
+ {/if}
158
+
159
+ <Label
160
+ text={labelText}
161
+ for={field.attribute}
162
+ capitalize={true}
163
+ required={fieldRequired && !readonly}
164
+ />
165
+
166
+ {#if field.type === 'boolean'}
167
+ <input
168
+ type="checkbox"
169
+ id={field.attribute}
170
+ {name}
171
+ class="toggle toggle-primary"
172
+ checked={!!saved}
173
+ disabled={readonly || fieldDisabled}
174
+ onchange={(e) => {
175
+ record[field.attribute] = (e.currentTarget as HTMLInputElement).checked;
176
+ }}
177
+ />
178
+ {:else if field.type === 'file'}
179
+ {#if !readonly}
180
+ <input
181
+ type="file"
182
+ id={field.attribute}
183
+ {name}
184
+ class="file-input file-input-bordered w-full"
185
+ class:file-input-error={!!error}
186
+ disabled={fieldDisabled}
187
+ onchange={onFileChange}
188
+ />
189
+ {/if}
190
+ <!-- read-only file value is shown as the avatar above -->
191
+ {:else if field.type === 'select'}
192
+ {#if readonly}
193
+ <input
194
+ type="text"
195
+ id={field.attribute}
196
+ class="input input-bordered w-full"
197
+ value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
198
+ disabled
199
+ />
200
+ {:else}
201
+ <Select
202
+ id={field.attribute}
203
+ name={field.attribute}
204
+ bind:value={record[field.attribute] as string}
205
+ options={selectOptions}
206
+ search={field.search}
207
+ placeholder={field.placeholder}
208
+ disabled={fieldDisabled}
209
+ {error}
210
+ />
211
+ {/if}
212
+ {:else if field.type === 'datetime'}
213
+ {#if readonly}
214
+ <input
215
+ type="text"
216
+ id={field.attribute}
217
+ class="input input-bordered w-full"
218
+ value={formattedValue}
219
+ disabled
220
+ />
221
+ {:else}
222
+ <input
223
+ type="hidden"
224
+ {name}
225
+ value={String(record[field.attribute] ?? '')}
226
+ disabled={fieldDisabled}
227
+ />
228
+ <div class="flex gap-2">
229
+ <button
230
+ type="button"
231
+ class="input input-bordered w-full text-left font-normal"
232
+ class:opacity-40={!record[field.attribute]}
233
+ disabled={fieldDisabled}
234
+ popovertarget={datePopId}
235
+ style="anchor-name:{dateAnchorName}"
236
+ >
237
+ {record[field.attribute] ? formattedDatePart : (field.placeholder ?? '')}
238
+ </button>
239
+ <input
240
+ type="time"
241
+ class="input input-bordered w-32 shrink-0"
242
+ value={timePart}
243
+ disabled={fieldDisabled || !record[field.attribute]}
244
+ onchange={(e) => setDateTime(datePart, (e.currentTarget as HTMLInputElement).value)}
245
+ />
246
+ </div>
247
+ <div
248
+ popover="auto"
249
+ id={datePopId}
250
+ bind:this={datePopoverEl}
251
+ style="position-anchor:{dateAnchorName}; position-try-fallbacks:flip-block;"
252
+ class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
253
+ >
254
+ <calendar-date class="cally" bind:this={calendarDateEl} value={datePart}>
255
+ <svg
256
+ aria-label={strings.previous}
257
+ class="fill-current size-4"
258
+ {...{ slot: 'previous' }}
259
+ xmlns="http://www.w3.org/2000/svg"
260
+ viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg
261
+ >
262
+ <svg
263
+ aria-label={strings.next}
264
+ class="fill-current size-4"
265
+ {...{ slot: 'next' }}
266
+ xmlns="http://www.w3.org/2000/svg"
267
+ viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg
268
+ >
269
+ <calendar-month></calendar-month>
270
+ </calendar-date>
271
+ </div>
272
+ {/if}
273
+ {:else if field.type === 'textarea'}
274
+ {#if readonly}
275
+ <textarea
276
+ id={field.attribute}
277
+ rows={field.rows}
278
+ class="textarea textarea-bordered bg-base-100 w-full"
279
+ value={formattedValue}
280
+ disabled
281
+ ></textarea>
282
+ {:else}
283
+ <textarea
284
+ id={field.attribute}
285
+ {name}
286
+ rows={field.rows}
287
+ placeholder={field.placeholder ?? ''}
288
+ bind:value={record[field.attribute]}
289
+ class="textarea textarea-bordered bg-base-100 w-full"
290
+ class:textarea-error={!!error}
291
+ disabled={fieldDisabled}
292
+ ></textarea>
293
+ {/if}
294
+ {:else if field.type === 'embedded'}
295
+ <EmbeddedField {field} bind:record {readonly} />
296
+ {:else if field.type === 'multiselect'}
297
+ {#if readonly}
298
+ <input
299
+ type="text"
300
+ id={field.attribute}
301
+ class="input input-bordered w-full"
302
+ value={(Array.isArray(saved) ? saved : [])
303
+ .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
304
+ .join(', ')}
305
+ disabled
306
+ />
307
+ {:else}
308
+ <MultiSelect
309
+ id={field.attribute}
310
+ name={field.attribute}
311
+ bind:value={record[field.attribute] as string[]}
312
+ options={selectOptions}
313
+ search={field.search}
314
+ placeholder={field.placeholder}
315
+ disabled={fieldDisabled}
316
+ {error}
317
+ />
318
+ {/if}
319
+ {:else if field.type === 'tree'}
320
+ {#if readonly}
321
+ <input
322
+ type="text"
323
+ id={field.attribute}
324
+ class="input input-bordered w-full"
325
+ value={(Array.isArray(saved) ? saved : [])
326
+ .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
327
+ .join(', ')}
328
+ disabled
329
+ />
330
+ {:else}
331
+ <Tree
332
+ name={field.attribute}
333
+ bind:value={record[field.attribute] as string[]}
334
+ options={selectOptions}
335
+ disabled={fieldDisabled}
336
+ defaultExpanded={field.defaultExpanded ?? true}
337
+ />
338
+ {/if}
339
+ {:else if readonly}
340
+ {#if field.formatter}
341
+ <div
342
+ id={field.attribute}
343
+ class="input input-bordered w-full bg-base-200 border-base-200 text-base-content/40 shadow-none cursor-not-allowed"
344
+ >
345
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
346
+ {@html formattedValue}
347
+ </div>
348
+ {:else}
349
+ <input
350
+ type={field.type ?? 'text'}
351
+ id={field.attribute}
352
+ class="input input-bordered w-full"
353
+ value={formattedValue}
354
+ disabled
355
+ />
356
+ {/if}
357
+ {:else}
358
+ <input
359
+ type={field.type ?? 'text'}
360
+ id={field.attribute}
361
+ {name}
362
+ placeholder={field.placeholder ?? ''}
363
+ bind:value={record[field.attribute]}
364
+ autocomplete={field.autocomplete}
365
+ step={field.type === 'number' ? 'any' : undefined}
366
+ class="input input-bordered w-full"
367
+ class:input-error={!!error}
368
+ disabled={fieldDisabled}
369
+ />
370
+ {/if}
371
+
372
+ {#if error}
373
+ <span class="text-error text-xs">{error}</span>
374
+ {/if}
375
+ </div>
376
+ {/if}