runeforge 0.0.29 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,263 +1,311 @@
1
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';
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
12
 
13
- const strings = getStrings();
13
+ const strings = getStrings();
14
14
 
15
- onMount(() => {
16
- import('cally');
17
- });
15
+ onMount(() => {
16
+ import('cally');
17
+ });
18
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();
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
32
 
33
- const datePopId = $props.id();
34
- const dateAnchorName = `--date-anchor-${datePopId}`;
35
- let datePopoverEl: HTMLElement | undefined = $state();
33
+ const datePopId = $props.id();
34
+ const dateAnchorName = `--date-anchor-${datePopId}`;
35
+ let datePopoverEl: HTMLElement | undefined = $state();
36
36
 
37
- const name = $derived(readonly ? undefined : field.attribute);
38
- const labelText = $derived(fieldLabel(field));
39
- let filePreview = $state<string | null>(null);
37
+ const name = $derived(readonly ? undefined : field.attribute);
38
+ const labelText = $derived(fieldLabel(field));
39
+ let filePreview = $state<string | null>(null);
40
40
 
41
- function onFileChange(e: Event & { currentTarget: HTMLInputElement }) {
42
- const file = e.currentTarget.files?.[0];
43
- if (filePreview) URL.revokeObjectURL(filePreview);
44
- filePreview = file && file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
45
- }
41
+ function onFileChange(e: Event & { currentTarget: HTMLInputElement }) {
42
+ const file = e.currentTarget.files?.[0];
43
+ if (filePreview) URL.revokeObjectURL(filePreview);
44
+ filePreview = file && file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
45
+ }
46
46
 
47
- $effect(() => () => {
48
- if (filePreview) URL.revokeObjectURL(filePreview);
49
- });
47
+ $effect(() => () => {
48
+ if (filePreview) URL.revokeObjectURL(filePreview);
49
+ });
50
50
 
51
- const saved = $derived(record[field.attribute]);
52
- const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
53
- const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
54
- const displayValue = $derived(saved == null ? '' : String(saved));
55
- const selectOptions = $derived(field.dependentOptions ? field.dependentOptions(record) : (field.options ?? []));
56
- const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
57
- const fieldRequired = $derived(typeof field.required === 'function' ? field.required(record) : !!field.required);
58
- const fieldHidden = $derived(typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden);
59
- const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
51
+ const saved = $derived(record[field.attribute]);
52
+ const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
53
+ const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
54
+ const displayValue = $derived(saved == null ? '' : String(saved));
55
+ // Only for readonly branches with no other resolution mechanism (select/
56
+ // multiselect/tree already resolve their own label from `options`, and
57
+ // some of their formatters return HTML meant for a table cell, not plain
58
+ // input text). Falls back to `displayValue` so a value with no formatter
59
+ // still renders instead of going blank.
60
+ const formattedValue = $derived(
61
+ readonly && field.formatter ? field.formatter(saved, record) : displayValue
62
+ );
63
+ const selectOptions = $derived(
64
+ field.dependentOptions ? field.dependentOptions(record) : (field.options ?? [])
65
+ );
66
+ const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
67
+ const fieldRequired = $derived(
68
+ typeof field.required === 'function' ? field.required(record) : !!field.required
69
+ );
70
+ const fieldHidden = $derived(
71
+ typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden
72
+ );
73
+ const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
60
74
 
61
- $effect(() => {
62
- if (!field.dependentOptions || isMultiValued) return;
63
- const current = record[field.attribute];
64
- if (current && !selectOptions.some((o) => o.value === String(current))) {
65
- record[field.attribute] = '';
66
- }
67
- });
75
+ $effect(() => {
76
+ if (!field.dependentOptions || isMultiValued) return;
77
+ const current = record[field.attribute];
78
+ if (current && !selectOptions.some((o) => o.value === String(current))) {
79
+ record[field.attribute] = '';
80
+ }
81
+ });
68
82
 
69
- $effect(() => {
70
- if (!field.dependentOptions || !isMultiValued) return;
71
- const current = record[field.attribute];
72
- if (!Array.isArray(current)) return;
73
- const validValues = new Set(selectOptions.map((o) => o.value));
74
- const pruned = current.filter((v) => validValues.has(String(v)));
75
- if (pruned.length !== current.length) {
76
- record[field.attribute] = pruned;
77
- }
78
- });
83
+ $effect(() => {
84
+ if (!field.dependentOptions || !isMultiValued) return;
85
+ const current = record[field.attribute];
86
+ if (!Array.isArray(current)) return;
87
+ const validValues = new Set(selectOptions.map((o) => o.value));
88
+ const pruned = current.filter((v) => validValues.has(String(v)));
89
+ if (pruned.length !== current.length) {
90
+ record[field.attribute] = pruned;
91
+ }
92
+ });
79
93
  </script>
80
94
 
81
95
  {#if !fieldHidden}
82
- <div class="flex flex-col gap-1 {className}">
83
- {#if field.type === 'file'}
84
- <div class="flex justify-center">
85
- <Avatar src={preview} text={avatarInitials} alt={labelText} class="w-20 rounded-full" textClass="text-xl" />
86
- </div>
87
- {/if}
96
+ <div class="flex flex-col gap-1 {className}">
97
+ {#if field.type === 'file'}
98
+ <div class="flex justify-center">
99
+ <Avatar
100
+ src={preview}
101
+ text={avatarInitials}
102
+ alt={labelText}
103
+ class="w-20 rounded-full"
104
+ textClass="text-xl"
105
+ />
106
+ </div>
107
+ {/if}
88
108
 
89
- <Label
90
- text={labelText}
91
- for={field.attribute}
92
- capitalize={true}
93
- required={fieldRequired && !readonly}
94
- />
109
+ <Label
110
+ text={labelText}
111
+ for={field.attribute}
112
+ capitalize={true}
113
+ required={fieldRequired && !readonly}
114
+ />
95
115
 
96
- {#if field.type === 'boolean'}
97
- <input
98
- type="checkbox"
99
- id={field.attribute}
100
- {name}
101
- class="toggle toggle-primary"
102
- checked={!!saved}
103
- disabled={readonly || fieldDisabled}
104
- onchange={(e) => { record[field.attribute] = (e.currentTarget as HTMLInputElement).checked; }}
105
- />
106
- {:else if field.type === 'file'}
107
- {#if !readonly}
108
- <input
109
- type="file"
110
- id={field.attribute}
111
- {name}
112
- class="file-input file-input-bordered w-full"
113
- class:file-input-error={!!error}
114
- disabled={fieldDisabled}
115
- onchange={onFileChange}
116
- />
117
- {/if}
118
- <!-- read-only file value is shown as the avatar above -->
119
- {:else if field.type === 'select'}
120
- {#if readonly}
121
- <input
122
- type="text"
123
- id={field.attribute}
124
- class="input input-bordered w-full"
125
- value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
126
- disabled
127
- />
128
- {:else}
129
- <Select
130
- name={field.attribute}
131
- bind:value={record[field.attribute] as string}
132
- options={selectOptions}
133
- search={field.search}
134
- placeholder={field.placeholder}
135
- disabled={fieldDisabled}
136
- {error}
137
- />
138
- {/if}
139
- {:else if field.type === 'datetime'}
140
- {#if readonly}
141
- <input
142
- type="text"
143
- id={field.attribute}
144
- class="input input-bordered w-full"
145
- value={displayValue}
146
- disabled
147
- />
148
- {:else}
149
- <input type="hidden" {name} value={String(record[field.attribute] ?? '')} disabled={fieldDisabled} />
150
- <button
151
- type="button"
152
- class="input input-bordered w-full text-left font-normal"
153
- class:opacity-40={!record[field.attribute]}
154
- disabled={fieldDisabled}
155
- popovertarget={datePopId}
156
- style="anchor-name:{dateAnchorName}"
157
- >
158
- {record[field.attribute] ? displayValue : (field.placeholder ?? '')}
159
- </button>
160
- <div
161
- popover="auto"
162
- id={datePopId}
163
- bind:this={datePopoverEl}
164
- style="position-anchor:{dateAnchorName}; position-try-fallbacks:flip-block;"
165
- class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
166
- >
167
- <calendar-date
168
- class="cally"
169
- value={String(record[field.attribute] ?? '')}
170
- onchange={(e: Event) => {
171
- if (fieldDisabled) return;
172
- record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value;
173
- datePopoverEl?.hidePopover();
174
- }}
175
- >
176
- <svg aria-label={strings.previous} class="fill-current size-4" {...{"slot": "previous"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
177
- <svg aria-label={strings.next} class="fill-current size-4" {...{"slot": "next"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
178
- <calendar-month></calendar-month>
179
- </calendar-date>
180
- </div>
181
- {/if}
182
- {:else if field.type === 'textarea'}
183
- {#if readonly}
184
- <textarea id={field.attribute} class="textarea textarea-bordered bg-base-100 w-full" value={displayValue} disabled></textarea>
185
- {:else}
186
- <textarea
187
- id={field.attribute}
188
- {name}
189
- placeholder={field.placeholder ?? ''}
190
- bind:value={record[field.attribute]}
191
- class="textarea textarea-bordered bg-base-100 w-full"
192
- class:textarea-error={!!error}
193
- disabled={fieldDisabled}
194
- ></textarea>
195
- {/if}
196
- {:else if field.type === 'embedded'}
197
- <EmbeddedField {field} bind:record {readonly} />
198
- {:else if field.type === 'multiselect'}
199
- {#if readonly}
200
- <input
201
- type="text"
202
- id={field.attribute}
203
- class="input input-bordered w-full"
204
- value={(Array.isArray(saved) ? saved : []).map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v)).join(', ')}
205
- disabled
206
- />
207
- {:else}
208
- <MultiSelect
209
- name={field.attribute}
210
- bind:value={record[field.attribute] as string[]}
211
- options={selectOptions}
212
- search={field.search}
213
- placeholder={field.placeholder}
214
- disabled={fieldDisabled}
215
- {error}
216
- />
217
- {/if}
218
- {:else if field.type === 'tree'}
219
- {#if readonly}
220
- <input
221
- type="text"
222
- id={field.attribute}
223
- class="input input-bordered w-full"
224
- value={(Array.isArray(saved) ? saved : []).map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v)).join(', ')}
225
- disabled
226
- />
227
- {:else}
228
- <Tree
229
- name={field.attribute}
230
- bind:value={record[field.attribute] as string[]}
231
- options={selectOptions}
232
- disabled={fieldDisabled}
233
- defaultExpanded={field.defaultExpanded ?? true}
234
- />
235
- {/if}
236
- {:else if readonly}
237
- <input
238
- type={field.type ?? 'text'}
239
- id={field.attribute}
240
- class="input input-bordered w-full"
241
- value={displayValue}
242
- disabled
243
- />
244
- {:else}
245
- <input
246
- type={field.type ?? 'text'}
247
- id={field.attribute}
248
- {name}
249
- placeholder={field.placeholder ?? ''}
250
- bind:value={record[field.attribute]}
251
- autocomplete={field.autocomplete}
252
- step={field.type === 'number' ? 'any' : undefined}
253
- class="input input-bordered w-full"
254
- class:input-error={!!error}
255
- disabled={fieldDisabled}
256
- />
257
- {/if}
116
+ {#if field.type === 'boolean'}
117
+ <input
118
+ type="checkbox"
119
+ id={field.attribute}
120
+ {name}
121
+ class="toggle toggle-primary"
122
+ checked={!!saved}
123
+ disabled={readonly || fieldDisabled}
124
+ onchange={(e) => {
125
+ record[field.attribute] = (e.currentTarget as HTMLInputElement).checked;
126
+ }}
127
+ />
128
+ {:else if field.type === 'file'}
129
+ {#if !readonly}
130
+ <input
131
+ type="file"
132
+ id={field.attribute}
133
+ {name}
134
+ class="file-input file-input-bordered w-full"
135
+ class:file-input-error={!!error}
136
+ disabled={fieldDisabled}
137
+ onchange={onFileChange}
138
+ />
139
+ {/if}
140
+ <!-- read-only file value is shown as the avatar above -->
141
+ {:else if field.type === 'select'}
142
+ {#if readonly}
143
+ <input
144
+ type="text"
145
+ id={field.attribute}
146
+ class="input input-bordered w-full"
147
+ value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
148
+ disabled
149
+ />
150
+ {:else}
151
+ <Select
152
+ name={field.attribute}
153
+ bind:value={record[field.attribute] as string}
154
+ options={selectOptions}
155
+ search={field.search}
156
+ placeholder={field.placeholder}
157
+ disabled={fieldDisabled}
158
+ {error}
159
+ />
160
+ {/if}
161
+ {:else if field.type === 'datetime'}
162
+ {#if readonly}
163
+ <input
164
+ type="text"
165
+ id={field.attribute}
166
+ class="input input-bordered w-full"
167
+ value={formattedValue}
168
+ disabled
169
+ />
170
+ {:else}
171
+ <input
172
+ type="hidden"
173
+ {name}
174
+ value={String(record[field.attribute] ?? '')}
175
+ disabled={fieldDisabled}
176
+ />
177
+ <button
178
+ type="button"
179
+ class="input input-bordered w-full text-left font-normal"
180
+ class:opacity-40={!record[field.attribute]}
181
+ disabled={fieldDisabled}
182
+ popovertarget={datePopId}
183
+ style="anchor-name:{dateAnchorName}"
184
+ >
185
+ {record[field.attribute] ? displayValue : (field.placeholder ?? '')}
186
+ </button>
187
+ <div
188
+ popover="auto"
189
+ id={datePopId}
190
+ bind:this={datePopoverEl}
191
+ style="position-anchor:{dateAnchorName}; position-try-fallbacks:flip-block;"
192
+ class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
193
+ >
194
+ <calendar-date
195
+ class="cally"
196
+ value={String(record[field.attribute] ?? '')}
197
+ onchange={(e: Event) => {
198
+ if (fieldDisabled) return;
199
+ record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value;
200
+ datePopoverEl?.hidePopover();
201
+ }}
202
+ >
203
+ <svg
204
+ aria-label={strings.previous}
205
+ class="fill-current size-4"
206
+ {...{ slot: 'previous' }}
207
+ xmlns="http://www.w3.org/2000/svg"
208
+ viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg
209
+ >
210
+ <svg
211
+ aria-label={strings.next}
212
+ class="fill-current size-4"
213
+ {...{ slot: 'next' }}
214
+ xmlns="http://www.w3.org/2000/svg"
215
+ viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg
216
+ >
217
+ <calendar-month></calendar-month>
218
+ </calendar-date>
219
+ </div>
220
+ {/if}
221
+ {:else if field.type === 'textarea'}
222
+ {#if readonly}
223
+ <textarea
224
+ id={field.attribute}
225
+ class="textarea textarea-bordered bg-base-100 w-full"
226
+ value={formattedValue}
227
+ disabled
228
+ ></textarea>
229
+ {:else}
230
+ <textarea
231
+ id={field.attribute}
232
+ {name}
233
+ placeholder={field.placeholder ?? ''}
234
+ bind:value={record[field.attribute]}
235
+ class="textarea textarea-bordered bg-base-100 w-full"
236
+ class:textarea-error={!!error}
237
+ disabled={fieldDisabled}
238
+ ></textarea>
239
+ {/if}
240
+ {:else if field.type === 'embedded'}
241
+ <EmbeddedField {field} bind:record {readonly} />
242
+ {:else if field.type === 'multiselect'}
243
+ {#if readonly}
244
+ <input
245
+ type="text"
246
+ id={field.attribute}
247
+ class="input input-bordered w-full"
248
+ value={(Array.isArray(saved) ? saved : [])
249
+ .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
250
+ .join(', ')}
251
+ disabled
252
+ />
253
+ {:else}
254
+ <MultiSelect
255
+ name={field.attribute}
256
+ bind:value={record[field.attribute] as string[]}
257
+ options={selectOptions}
258
+ search={field.search}
259
+ placeholder={field.placeholder}
260
+ disabled={fieldDisabled}
261
+ {error}
262
+ />
263
+ {/if}
264
+ {:else if field.type === 'tree'}
265
+ {#if readonly}
266
+ <input
267
+ type="text"
268
+ id={field.attribute}
269
+ class="input input-bordered w-full"
270
+ value={(Array.isArray(saved) ? saved : [])
271
+ .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
272
+ .join(', ')}
273
+ disabled
274
+ />
275
+ {:else}
276
+ <Tree
277
+ name={field.attribute}
278
+ bind:value={record[field.attribute] as string[]}
279
+ options={selectOptions}
280
+ disabled={fieldDisabled}
281
+ defaultExpanded={field.defaultExpanded ?? true}
282
+ />
283
+ {/if}
284
+ {:else if readonly}
285
+ <input
286
+ type={field.type ?? 'text'}
287
+ id={field.attribute}
288
+ class="input input-bordered w-full"
289
+ value={formattedValue}
290
+ disabled
291
+ />
292
+ {:else}
293
+ <input
294
+ type={field.type ?? 'text'}
295
+ id={field.attribute}
296
+ {name}
297
+ placeholder={field.placeholder ?? ''}
298
+ bind:value={record[field.attribute]}
299
+ autocomplete={field.autocomplete}
300
+ step={field.type === 'number' ? 'any' : undefined}
301
+ class="input input-bordered w-full"
302
+ class:input-error={!!error}
303
+ disabled={fieldDisabled}
304
+ />
305
+ {/if}
258
306
 
259
- {#if error}
260
- <span class="text-error text-xs">{error}</span>
261
- {/if}
262
- </div>
307
+ {#if error}
308
+ <span class="text-error text-xs">{error}</span>
309
+ {/if}
310
+ </div>
263
311
  {/if}
@@ -19,7 +19,10 @@ export function inferType(key, value) {
19
19
  return 'email';
20
20
  if (k.includes('password') || k.includes('hash'))
21
21
  return 'password';
22
- if (k.includes('description') || k.includes('bio') || k.includes('notes') || k.includes('content'))
22
+ if (k.includes('description') ||
23
+ k.includes('bio') ||
24
+ k.includes('notes') ||
25
+ k.includes('content'))
23
26
  return 'textarea';
24
27
  return 'text';
25
28
  }
@@ -46,6 +49,7 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
46
49
  disabled: m.disabled,
47
50
  hidden: m.hidden,
48
51
  seed: m.seed,
52
+ formatter: resolveFormatter(m, data),
49
53
  groupedAs: m.groupedAs,
50
54
  min: m.min,
51
55
  max: m.max,
@@ -53,9 +57,7 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
53
57
  minLength: m.minLength,
54
58
  maxLength: m.maxLength,
55
59
  pattern: m.pattern,
56
- fields: m.fields
57
- ? buildFieldDefinitions(m.fields, data, excludedFlag, new Set())
58
- : undefined,
60
+ fields: m.fields ? buildFieldDefinitions(m.fields, data, excludedFlag, new Set()) : undefined,
59
61
  itemLabel: m.itemLabel,
60
62
  defaultExpanded: m.defaultExpanded,
61
63
  row: m.row
@@ -207,6 +207,7 @@
207
207
  ...actions.map((action) => ({
208
208
  label: action.label,
209
209
  icon: action.icon,
210
+ toggle: action.toggle,
210
211
  condition: action.condition,
211
212
  run: (item: T) => onAction?.(action, item)
212
213
  })),
@@ -230,22 +231,37 @@
230
231
  </script>
231
232
 
232
233
  {#snippet actionsCell(item: T)}
233
- <div class="flex justify-end gap-1">
234
+ <div class="flex justify-end items-center gap-1">
234
235
  {#each rowActions as action (action.label)}
235
236
  {#if action.condition?.(item) ?? true}
236
- {@const Icon = action.icon}
237
- <Button
238
- variant="ghost"
239
- class={['btn-xs', action.class]}
240
- title={action.label}
241
- aria-label={action.label}
242
- onclick={(e) => {
243
- e.stopPropagation();
244
- action.run(item);
245
- }}
246
- >
247
- <Icon class="size-4" />
248
- </Button>
237
+ {#if action.toggle}
238
+ <input
239
+ type="checkbox"
240
+ class={['toggle toggle-sm', action.class]}
241
+ title={action.label}
242
+ aria-label={action.label}
243
+ checked={action.toggle(item)}
244
+ onclick={(e) => {
245
+ e.preventDefault();
246
+ e.stopPropagation();
247
+ action.run(item);
248
+ }}
249
+ />
250
+ {:else}
251
+ {@const Icon = action.icon}
252
+ <Button
253
+ variant="ghost"
254
+ class={['btn-xs', action.class]}
255
+ title={action.label}
256
+ aria-label={action.label}
257
+ onclick={(e) => {
258
+ e.stopPropagation();
259
+ action.run(item);
260
+ }}
261
+ >
262
+ <Icon class="size-4" />
263
+ </Button>
264
+ {/if}
249
265
  {/if}
250
266
  {/each}
251
267
  </div>
@@ -34,6 +34,8 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
34
34
  disabled?: (record: Record<string, unknown>) => boolean;
35
35
  hidden?: boolean | ((record: Record<string, unknown>) => boolean);
36
36
  seed?: (instance: any) => unknown;
37
+ /** Read-only rendering only (the Read view, and any other readonly Field) */
38
+ formatter?: (value: unknown, record: Record<string, unknown>) => string;
37
39
  groupedAs?: string;
38
40
  min?: number;
39
41
  max?: number;
@@ -60,7 +62,19 @@ export interface ActionConfiguration<T extends object = Record<string, unknown>>
60
62
  }
61
63
  export interface CustomAction<T extends object = Record<string, unknown>> {
62
64
  label: string;
63
- icon: any;
65
+ /** Required unless `toggle` is set, which renders the action as a switch
66
+ * instead of an icon button. */
67
+ icon?: any;
68
+ /** Renders the row action as a real daisyUI toggle switch reflecting the
69
+ * item's current on/off state, instead of an icon button. Takes
70
+ * precedence over `icon` when both are set.
71
+ *
72
+ * The switch is controlled, not editable directly: clicking it never
73
+ * flips its own checked state, it still just calls `run` (opening `view`
74
+ * or navigating via `href`) like any other action. It only visually
75
+ * flips once the underlying item's data actually changes — e.g. after
76
+ * the action's modal confirms and the list reloads. */
77
+ toggle?: (item: T) => boolean;
64
78
  endpoint?: string;
65
79
  condition?: (item: T) => boolean;
66
80
  /** Renders as a modal-like panel when the action runs. Mutually exclusive
@@ -72,7 +86,9 @@ export interface CustomAction<T extends object = Record<string, unknown>> {
72
86
  }
73
87
  export interface RowAction<T extends object = Record<string, unknown>> {
74
88
  label: string;
75
- icon: any;
89
+ icon?: any;
90
+ /** See `CustomAction.toggle` — same controlled-switch rendering. */
91
+ toggle?: (item: T) => boolean;
76
92
  class?: string;
77
93
  condition?: (item: T) => boolean;
78
94
  run: (item: T) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",