runeforge 0.0.56 → 0.0.58

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.
package/README.md CHANGED
@@ -164,9 +164,12 @@ Responsive overrides work too:
164
164
  | `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
165
165
  | `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
166
166
  | `--runeforge-tree-max-height` | `24rem` | Max height of a `tree` field before it scrolls internally |
167
+ | `--runeforge-sticky-header-top` | `0` | Offset of the sticky title/breadcrumbs/alert block in Create, Update, and Read views — raise it if your app already has its own sticky top bar taking up space |
167
168
 
168
169
  Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
169
170
 
171
+ The title, breadcrumbs and any error/success alert at the top of the Create, Update and Read views stay pinned (`position: sticky`) to the top of the nearest scrolling ancestor, so they — and a validation error that just appeared — stay visible while a long form scrolls underneath. Set `--runeforge-sticky-header-top` if that ancestor already has its own fixed/sticky bar above this block.
172
+
170
173
  ---
171
174
 
172
175
  ## Configuration
@@ -364,6 +367,8 @@ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a
364
367
  | `integer` | `boolean` | `number` | Rejects non-whole numbers |
365
368
  | `minLength` / `maxLength` | `number` | text-like | Character-count validation |
366
369
  | `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
370
+ | `validate` | `(value, record) => string \| undefined` | all | Custom validation, including cross-field rules — see [Validation](#validation) |
371
+ | `actions` | `FieldButtonAction[]` | all | Extra buttons rendered next to the field's label — see [Field actions](#field-actions) |
367
372
  | `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
368
373
  | `hidden` | `boolean \| (record) => boolean` | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see [Conditional fields](#conditional-fields) |
369
374
  | `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
@@ -428,6 +433,27 @@ quantity: {
428
433
 
429
434
  The label's required marker and the submit-time check both re-evaluate the same way `disabled` does — see [Conditional fields](#conditional-fields).
430
435
 
436
+ For anything the built-in rules above don't cover — including a rule that depends on another field, not just this one — pass `validate`. It runs after this field's built-in rules pass (and is skipped if one of them already failed), and receives both the field's own value and the full draft record, so the same hook covers a lone-field rule and a cross-field one alike:
437
+
438
+ ```ts
439
+ submissionDate: {
440
+ label: 'Submission date',
441
+ type: AttributeType.datetime,
442
+ },
443
+ extensionDate: {
444
+ label: 'Extension date',
445
+ type: AttributeType.datetime,
446
+ validate: (value, record) =>
447
+ typeof value === 'string' &&
448
+ typeof record.submissionDate === 'string' &&
449
+ value <= record.submissionDate
450
+ ? 'Extension date must be later than the submission date'
451
+ : undefined,
452
+ },
453
+ ```
454
+
455
+ There's deliberately no separate, form-wide validation hook — every error belongs to the field whose value is wrong, even when the rule reads a sibling's value to decide that, so a per-field `validate` is all that's needed.
456
+
431
457
  > [!TIP]
432
458
  > Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
433
459
 
@@ -476,6 +502,31 @@ cardExpiry: {
476
502
 
477
503
  Switching `paymentMethod` between `card` and `cash` swaps which fields are present, live, in the same create/edit view — no separate step or modal needed to collect the payment-specific details.
478
504
 
505
+ ### Field actions
506
+
507
+ `actions` renders one or more buttons next to a field's label, for a client-side transform that doesn't belong to any field of its own — nothing is submitted, nothing hits the server, the button just runs synchronously and updates the form. Useful for things like normalizing free text as the user types it:
508
+
509
+ ```ts
510
+ import Edit from './icons/Edit.svelte';
511
+
512
+ description: {
513
+ label: 'Description',
514
+ type: AttributeType.textarea,
515
+ actions: [
516
+ {
517
+ label: 'Capitalize',
518
+ icon: Edit,
519
+ run: (value, record, setField) => {
520
+ const str = String(value ?? '');
521
+ setField('description', str ? str[0].toUpperCase() + str.slice(1).toLowerCase() : str);
522
+ },
523
+ },
524
+ ],
525
+ },
526
+ ```
527
+
528
+ `run` receives the field's current value, the form's full draft record, and a `setField(attribute, value)` setter — which can just as well target a sibling attribute instead of the field the button sits next to. `icon` is a Svelte component, same convention as [custom row actions](#custom-row-actions). `condition` hides the button in certain conditions, re-evaluated live like `disabled`. Actions never render in the read-only view.
529
+
479
530
  ### Field grouping
480
531
 
481
532
  Fields sharing the same `groupedAs` string render together inside a titled `fieldset`, at the position of the group's first field. Fields without `groupedAs` keep the original flat layout.
@@ -1011,7 +1062,7 @@ Individual form primitives styled with DaisyUI:
1011
1062
  - `Avatar` — user avatar display
1012
1063
  - `Modal` — DaisyUI modal wrapper. Size it with Tailwind utility classes via `class` (e.g. `class="max-w-4xl"`), or with explicit `width`/`maxWidth`/`height`/`maxHeight` CSS lengths, which are applied as inline styles and take priority over `class`
1013
1064
  - `Breadcrumbs` — navigation breadcrumb trail
1014
- - `IconRenderer` — renders icons from the active icon set
1065
+ - `IconRenderer` — renders a named SVG file fetched from `setIconAssetsPath` (see [Icon System](#icon-system))
1015
1066
 
1016
1067
  ---
1017
1068
 
@@ -1152,7 +1203,16 @@ export const userMeta = {
1152
1203
 
1153
1204
  ### Example: icon column
1154
1205
 
1155
- A simpler case — render a Bootstrap icon by name stored as a plain string:
1206
+ A simpler case — render an SVG file by name, stored as a plain string (e.g. `bar-chart.svg`). Drop the curated set of icons your app actually uses under `static/icons/` (or any path), point `IconRenderer`/`IconCell` at it once with `setIconAssetsPath`, and the field just stores the filename:
1207
+
1208
+ ```ts
1209
+ <!-- routes/+layout.svelte -->
1210
+ <script lang="ts">
1211
+ import { setIconAssetsPath } from 'runeforge';
1212
+
1213
+ setIconAssetsPath('/icons');
1214
+ </script>
1215
+ ```
1156
1216
 
1157
1217
  ```ts
1158
1218
  <!-- components/IconCell.svelte -->
@@ -1174,6 +1234,8 @@ icon: {
1174
1234
  },
1175
1235
  ```
1176
1236
 
1237
+ Only the SVG files a row actually references are ever fetched — nothing is bundled or preloaded up front, unlike importing an entire icon package to look up a component by name.
1238
+
1177
1239
  > [!TIP]
1178
1240
  > Both `AvatarCell` and `IconCell` are included in the package and ready to use — you don't need to build them from scratch:
1179
1241
  >
@@ -1274,18 +1336,20 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
1274
1336
 
1275
1337
  ## Icon System
1276
1338
 
1277
- Runeforge ships with a default icon set. To use Bootstrap Icons instead:
1339
+ Runeforge ships with a default icon set for the fixed set of CRUD action icons (sort, filter, create, edit, delete, ...). To use Bootstrap Icons instead:
1278
1340
 
1279
1341
  ```ts
1280
1342
  <script>
1281
- import { setIconSet, bootstrapIcons } from 'runeforge';
1343
+ import { setIconSet, bootstrapIconSet } from 'runeforge';
1282
1344
 
1283
- setIconSet(bootstrapIcons);
1345
+ setIconSet(bootstrapIconSet);
1284
1346
  </script>
1285
1347
  ```
1286
1348
 
1287
1349
  You can also provide a fully custom icon set by passing an object that satisfies the icon set interface.
1288
1350
 
1351
+ For per-row/per-entity icons chosen dynamically by name (e.g. a "pick an icon" field on a model), don't use `CRUDIconSet` — see [Example: icon column](#example-icon-column) for `IconRenderer`/`setIconAssetsPath` instead. That path fetches one SVG file per name from a static folder in your app, rather than importing an entire icon package to resolve a component by name.
1352
+
1289
1353
  ---
1290
1354
 
1291
1355
  ## Running Tests
@@ -1,22 +1,73 @@
1
+ <script lang="ts" module>
2
+ // Module-scoped so every IconRenderer instance shares one fetch/cache per
3
+ // URL instead of re-requesting the same SVG file.
4
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
5
+ const cache = new Map<string, Promise<string | null>>();
6
+
7
+ async function loadSvg(url: string): Promise<string | null> {
8
+ let pending = cache.get(url);
9
+ if (!pending) {
10
+ pending = fetch(url)
11
+ .then((res) => (res.ok ? res.text() : null))
12
+ .catch(() => null);
13
+ cache.set(url, pending);
14
+ }
15
+ const svg = await pending;
16
+ if (svg === null) cache.delete(url);
17
+ return svg;
18
+ }
19
+
20
+ /** Stamps `class`/width/height onto the fetched markup's root `<svg>` tag
21
+ * so Tailwind sizing and `currentColor` theming apply the same way they
22
+ * would to a hand-written inline SVG. */
23
+ function styleSvg(svg: string, className: string, size: string): string {
24
+ return svg.replace(
25
+ '<svg',
26
+ `<svg class="${className}" width="${size}" height="${size}"`
27
+ );
28
+ }
29
+ </script>
30
+
1
31
  <script lang="ts">
2
- import { getIconSet } from '../icons/context.js';
32
+ import { getIconAssetsPath } from '../icons/context.js';
3
33
 
4
34
  let {
5
35
  name,
36
+ basePath,
6
37
  size = '1em',
7
38
  class: className = '',
8
39
  }: {
9
40
  name: string;
41
+ /** Overrides the `setIconAssetsPath` context value for this instance. */
42
+ basePath?: string;
10
43
  size?: string | number;
11
44
  class?: string;
12
45
  } = $props();
13
46
 
14
- const icons = $derived(getIconSet());
15
- const IconComponent = $derived(icons?.getByName?.(name) ?? null);
47
+ const resolvedBasePath = $derived(basePath ?? getIconAssetsPath());
48
+ const dimension = $derived(typeof size === 'number' ? `${size}px` : size);
49
+ const src = $derived(name ? `${resolvedBasePath}/${name}` : null);
50
+
51
+ let markup = $state<string | null>(null);
52
+
53
+ $effect(() => {
54
+ if (!src) {
55
+ markup = null;
56
+ return;
57
+ }
58
+ let cancelled = false;
59
+ loadSvg(src).then((svg) => {
60
+ if (!cancelled) markup = svg;
61
+ });
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ });
16
66
  </script>
17
67
 
18
- {#if IconComponent}
19
- <IconComponent {size} class={className} />
68
+ {#if markup}
69
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
70
+ {@html styleSvg(markup, className, dimension)}
20
71
  {:else}
21
72
  <span class={className} title={name}></span>
22
73
  {/if}
@@ -1,5 +1,7 @@
1
1
  type $$ComponentProps = {
2
2
  name: string;
3
+ /** Overrides the `setIconAssetsPath` context value for this instance. */
4
+ basePath?: string;
3
5
  size?: string | number;
4
6
  class?: string;
5
7
  };
@@ -1,6 +1,7 @@
1
1
  <script lang="ts" generics="T extends object = Record<string, unknown>">
2
2
  import { onMount } from 'svelte';
3
3
  import Avatar from '../Avatar.svelte';
4
+ import Button from '../form/Button.svelte';
4
5
  import Label from '../form/Label.svelte';
5
6
  import Select from '../form/Select.svelte';
6
7
  import MultiSelect from '../form/MultiSelect.svelte';
@@ -72,6 +73,9 @@
72
73
  typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden
73
74
  );
74
75
  const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
76
+ const fieldActions = $derived(
77
+ (field.actions ?? []).filter((action) => action.condition?.(record) ?? true)
78
+ );
75
79
 
76
80
  // cally's `change` event doesn't bubble, so the usual `onchange={...}` prop
77
81
  // never fires — Svelte 5 delegates events like `change` to a listener on
@@ -224,12 +228,36 @@
224
228
  </div>
225
229
  {/if}
226
230
 
227
- <Label
228
- text={labelText}
229
- for={field.attribute}
230
- capitalize={true}
231
- required={fieldRequired && !readonly}
232
- />
231
+ <div class="flex items-center justify-between gap-2">
232
+ <Label
233
+ text={labelText}
234
+ for={field.attribute}
235
+ capitalize={true}
236
+ required={fieldRequired && !readonly}
237
+ />
238
+ {#if !readonly && fieldActions.length > 0}
239
+ <div class="flex items-center gap-1">
240
+ {#each fieldActions as action (action.label)}
241
+ <Button
242
+ type="button"
243
+ variant="ghost"
244
+ class={['btn-xs', action.class]}
245
+ title={action.label}
246
+ onclick={() =>
247
+ action.run(record[field.attribute], record, (attribute, value) => {
248
+ record[attribute] = value;
249
+ })}
250
+ >
251
+ {#if action.icon}
252
+ {@const Icon = action.icon}
253
+ <Icon class="size-4" />
254
+ {/if}
255
+ {action.label}
256
+ </Button>
257
+ {/each}
258
+ </div>
259
+ {/if}
260
+ </div>
233
261
 
234
262
  {#if field.type === 'boolean'}
235
263
  <input
@@ -68,10 +68,12 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
68
68
  minLength: m.minLength,
69
69
  maxLength: m.maxLength,
70
70
  pattern: m.pattern,
71
+ validate: m.validate,
71
72
  rows: m.rows,
72
73
  fields: m.fields ? buildFieldDefinitions(m.fields, data, excludedFlag, new Set()) : undefined,
73
74
  itemLabel: m.itemLabel,
74
75
  defaultExpanded: m.defaultExpanded,
75
- row: m.row
76
+ row: m.row,
77
+ actions: m.actions
76
78
  }));
77
79
  }
@@ -22,6 +22,11 @@ export function validateAll(fields, formData, strings) {
22
22
  if (required && (!Array.isArray(items) || items.length === 0)) {
23
23
  errors[field.attribute] = strings.required(fieldLabel(field));
24
24
  }
25
+ if (!errors[field.attribute] && field.validate) {
26
+ const message = field.validate(items, record);
27
+ if (message)
28
+ errors[field.attribute] = message;
29
+ }
25
30
  continue;
26
31
  }
27
32
  const val = String(formData.get(field.attribute) ?? '').trim();
@@ -57,6 +62,11 @@ export function validateAll(fields, formData, strings) {
57
62
  errors[field.attribute] = strings.pattern(fieldLabel(field));
58
63
  }
59
64
  }
65
+ if (!errors[field.attribute] && field.validate) {
66
+ const message = field.validate(val, record);
67
+ if (message)
68
+ errors[field.attribute] = message;
69
+ }
60
70
  }
61
71
  return errors;
62
72
  }
@@ -78,35 +78,37 @@
78
78
 
79
79
  <div class="flex flex-col gap-6">
80
80
 
81
- <Header
82
- title={labelMany}
83
- breadcrumbs={[
84
- { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
85
- { label: creation.label ?? labelOne, icon: icons.create },
86
- ]}
87
- />
88
-
89
- {#if successMessage}
90
- <div role="alert" class="alert alert-success">
91
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
92
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
93
- </svg>
94
- <span class="text-sm">{successMessage}</span>
95
- </div>
96
- {/if}
97
-
98
- {#if errorEntries.length > 0}
99
- <div role="alert" class="alert alert-error">
100
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
101
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
102
- </svg>
103
- <ul class="list-disc list-inside text-sm">
104
- {#each errorEntries as [key, msg] (key)}
105
- <li>{msg}</li>
106
- {/each}
107
- </ul>
108
- </div>
109
- {/if}
81
+ <div class="sticky-header sticky z-10 flex flex-col gap-6 bg-base-100 pb-2">
82
+ <Header
83
+ title={labelMany}
84
+ breadcrumbs={[
85
+ { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
86
+ { label: creation.label ?? labelOne, icon: icons.create },
87
+ ]}
88
+ />
89
+
90
+ {#if successMessage}
91
+ <div role="alert" class="alert alert-success">
92
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
93
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
94
+ </svg>
95
+ <span class="text-sm">{successMessage}</span>
96
+ </div>
97
+ {/if}
98
+
99
+ {#if errorEntries.length > 0}
100
+ <div role="alert" class="alert alert-error">
101
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
102
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
103
+ </svg>
104
+ <ul class="list-disc list-inside text-sm">
105
+ {#each errorEntries as [key, msg] (key)}
106
+ <li>{msg}</li>
107
+ {/each}
108
+ </ul>
109
+ </div>
110
+ {/if}
111
+ </div>
110
112
 
111
113
  <form
112
114
  method="POST"
@@ -229,4 +231,8 @@
229
231
  form {
230
232
  max-width: var(--runeforge-form-max-width, 32rem);
231
233
  }
234
+
235
+ .sticky-header {
236
+ top: var(--runeforge-sticky-header-top, 0);
237
+ }
232
238
  </style>
@@ -67,13 +67,15 @@
67
67
 
68
68
  <div class="flex flex-col gap-6">
69
69
 
70
- <Header
71
- title={labelMany}
72
- breadcrumbs={[
73
- { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
74
- { label: read.label ?? labelOne, icon: icons.view },
75
- ]}
76
- />
70
+ <div class="sticky-header sticky z-10 bg-base-100 pb-2">
71
+ <Header
72
+ title={labelMany}
73
+ breadcrumbs={[
74
+ { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
75
+ { label: read.label ?? labelOne, icon: icons.view },
76
+ ]}
77
+ />
78
+ </div>
77
79
 
78
80
  <div class="fields-panel mx-auto flex w-full flex-col gap-4 px-4">
79
81
  {#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
@@ -121,4 +123,8 @@
121
123
  .fields-panel {
122
124
  max-width: var(--runeforge-form-max-width, 32rem);
123
125
  }
126
+
127
+ .sticky-header {
128
+ top: var(--runeforge-sticky-header-top, 0);
129
+ }
124
130
  </style>
@@ -80,26 +80,28 @@
80
80
 
81
81
  <div class="flex flex-col gap-6">
82
82
 
83
- <Header
84
- title={labelMany}
85
- breadcrumbs={[
86
- { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
87
- { label: update.label ?? labelOne, icon: icons.edit },
88
- ]}
89
- />
90
-
91
- {#if errorEntries.length > 0}
92
- <div role="alert" class="alert alert-error">
93
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
94
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
95
- </svg>
96
- <ul class="list-disc list-inside text-sm">
97
- {#each errorEntries as [key, msg] (key)}
98
- <li>{msg}</li>
99
- {/each}
100
- </ul>
101
- </div>
102
- {/if}
83
+ <div class="sticky-header sticky z-10 flex flex-col gap-6 bg-base-100 pb-2">
84
+ <Header
85
+ title={labelMany}
86
+ breadcrumbs={[
87
+ { label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
88
+ { label: update.label ?? labelOne, icon: icons.edit },
89
+ ]}
90
+ />
91
+
92
+ {#if errorEntries.length > 0}
93
+ <div role="alert" class="alert alert-error">
94
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
95
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
96
+ </svg>
97
+ <ul class="list-disc list-inside text-sm">
98
+ {#each errorEntries as [key, msg] (key)}
99
+ <li>{msg}</li>
100
+ {/each}
101
+ </ul>
102
+ </div>
103
+ {/if}
104
+ </div>
103
105
 
104
106
  <form
105
107
  method="POST"
@@ -205,4 +207,8 @@
205
207
  form {
206
208
  max-width: var(--runeforge-form-max-width, 32rem);
207
209
  }
210
+
211
+ .sticky-header {
212
+ top: var(--runeforge-sticky-header-top, 0);
213
+ }
208
214
  </style>
@@ -1,3 +1,8 @@
1
1
  import type { CRUDIconSet } from './types.js';
2
2
  export declare function setIconSet(icons: Partial<CRUDIconSet>): void;
3
3
  export declare function getIconSet(): CRUDIconSet | undefined;
4
+ /** Base path `IconRenderer`/`IconCell` fetch named SVG files from, e.g.
5
+ * `setIconAssetsPath('/icons/chapters')` for files served out of
6
+ * `static/icons/chapters/*.svg`. Defaults to `/icons`. */
7
+ export declare function setIconAssetsPath(path: string): void;
8
+ export declare function getIconAssetsPath(): string;
@@ -1,5 +1,6 @@
1
1
  import { getContext, setContext } from 'svelte';
2
2
  const KEY = Symbol('runeforge-icons');
3
+ const ASSETS_KEY = Symbol('runeforge-icon-assets-path');
3
4
  export function setIconSet(icons) {
4
5
  const existing = getContext(KEY);
5
6
  setContext(KEY, { ...existing, ...icons });
@@ -7,3 +8,12 @@ export function setIconSet(icons) {
7
8
  export function getIconSet() {
8
9
  return getContext(KEY);
9
10
  }
11
+ /** Base path `IconRenderer`/`IconCell` fetch named SVG files from, e.g.
12
+ * `setIconAssetsPath('/icons/chapters')` for files served out of
13
+ * `static/icons/chapters/*.svg`. Defaults to `/icons`. */
14
+ export function setIconAssetsPath(path) {
15
+ setContext(ASSETS_KEY, path);
16
+ }
17
+ export function getIconAssetsPath() {
18
+ return getContext(ASSETS_KEY) ?? '/icons';
19
+ }
@@ -9,8 +9,7 @@
9
9
  * import { bootstrapIconSet } from 'runeforge/icons/sets/bootstrap';
10
10
  * setIconSet(bootstrapIconSet);
11
11
  */
12
- import * as Icons from 'svelte-bootstrap-icons';
13
- const { ChevronExpand, CaretUpFill, CaretDownFill, Funnel, FunnelFill, Plus, Eye, PencilSquare, Trash3, HouseDoor, Folder, EyeSlash, X, Download, GripVertical, } = Icons;
12
+ import { ChevronExpand, CaretUpFill, CaretDownFill, Funnel, FunnelFill, Plus, Eye, PencilSquare, Trash3, HouseDoor, Folder, EyeSlash, X, Download, GripVertical, } from 'svelte-bootstrap-icons';
14
13
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
14
  function asIcon(c) { return c; }
16
15
  export const bootstrapIconSet = {
@@ -30,6 +29,4 @@ export const bootstrapIconSet = {
30
29
  passwordHide: asIcon(EyeSlash),
31
30
  download: asIcon(Download),
32
31
  grip: asIcon(GripVertical),
33
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
- getByName: (name) => asIcon(Icons[name]) ?? null,
35
32
  };
@@ -22,5 +22,4 @@ export interface CRUDIconSet {
22
22
  /** Drag handle shown at the start of each row when list reordering is
23
23
  * enabled. Optional so existing custom icon sets keep compiling. */
24
24
  grip?: IconComponent;
25
- getByName?: (name: string) => IconComponent | null;
26
25
  }
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { ColumnDefinition, FieldDefinition, ActionConfiguration, CustomActi
6
6
  export type { RuneforgeConfig } from './config/context.js';
7
7
  export { setConfig, getConfig } from './config/context.js';
8
8
  export type { CRUDIconSet, IconComponent } from './icons/types.js';
9
- export { setIconSet, getIconSet } from './icons/context.js';
9
+ export { setIconSet, getIconSet, setIconAssetsPath, getIconAssetsPath } from './icons/context.js';
10
10
  export { defaultIconSet } from './icons/sets/default.js';
11
11
  export { bootstrapIconSet } from './icons/sets/bootstrap.js';
12
12
  export type { RuneforgeStrings } from './i18n/types.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { AttributeType } from './types/attribute.js';
2
2
  export { setConfig, getConfig } from './config/context.js';
3
- export { setIconSet, getIconSet } from './icons/context.js';
3
+ export { setIconSet, getIconSet, setIconAssetsPath, getIconAssetsPath } from './icons/context.js';
4
4
  export { defaultIconSet } from './icons/sets/default.js';
5
5
  export { bootstrapIconSet } from './icons/sets/bootstrap.js';
6
6
  export { setStrings, getStrings } from './i18n/context.js';
@@ -34,6 +34,24 @@ export type SeedResolver = (instance: any) => unknown;
34
34
  /** Embedded fields only: renders a short summary for one item in the list.
35
35
  * Falls back to a dash-joined summary of the item's sub-field values. */
36
36
  export type EmbeddedItemLabelResolver = (item: Record<string, unknown>) => string;
37
+ export type ValidateResolver = (value: unknown, record: Record<string, unknown>) => string | undefined;
38
+ /** Sets a field's value from within a `FieldButtonAction.run` callback —
39
+ * usually the same field the button sits next to, but any sibling attribute
40
+ * works too. */
41
+ export type FieldSetter = (attribute: string, value: unknown) => void;
42
+ /** A button rendered next to a field's label that runs entirely client-side
43
+ * — no submit, no request — instead of persisting a model attribute of its
44
+ * own. See `AttributeMetadata.actions`. */
45
+ export type FieldButtonAction = {
46
+ label: string;
47
+ icon?: any;
48
+ class?: string;
49
+ /** Hide the button in certain conditions. Re-evaluated live, same as `disabled`. */
50
+ condition?: (record: Record<string, unknown>) => boolean;
51
+ /** Receives the field's current value, the form's full draft record, and a
52
+ * setter to write back into this field (or a sibling one). */
53
+ run: (value: unknown, record: Record<string, unknown>, setField: FieldSetter) => void;
54
+ };
37
55
  export type AttributeMetadata = {
38
56
  label?: string;
39
57
  type?: AttributeType;
@@ -91,6 +109,15 @@ export type AttributeMetadata = {
91
109
  minLength?: number;
92
110
  maxLength?: number;
93
111
  pattern?: string;
112
+ /** Custom validation, run after this field's built-in rules
113
+ * (required/min/max/minLength/maxLength/pattern) pass, and only when none
114
+ * of them already failed. Receives the field's submitted value and the
115
+ * full draft record (including sibling fields currently in the form), so
116
+ * the same hook covers a lone-field rule and a cross-field rule alike —
117
+ * e.g. an `extension_date` that must be later than the record's
118
+ * `submission_date`. Return an error message, or `undefined` when the
119
+ * value is valid. */
120
+ validate?: ValidateResolver;
94
121
  /** Textarea fields only: the HTML `rows` attribute, controlling height. */
95
122
  rows?: number;
96
123
  /** Embedded fields only: schema for each item added through the "+" modal. */
@@ -103,4 +130,8 @@ export type AttributeMetadata = {
103
130
  * stacked on mobile — see the Field rows section. Only merges fields that
104
131
  * are also in the same `groupedAs` bucket (or both ungrouped). */
105
132
  row?: string;
133
+ /** Extra buttons rendered next to this field's label — e.g. a "Capitalize"
134
+ * button that transforms the field's own value client-side. See "Field
135
+ * actions" in the README. */
136
+ actions?: FieldButtonAction[];
106
137
  };
@@ -1,6 +1,6 @@
1
1
  import type { Component } from 'svelte';
2
2
  import type { FullAutoFill } from 'svelte/elements';
3
- import type { AttributeType, SearchResolver, RequiredResolver, SelectOption } from './attribute.js';
3
+ import type { AttributeType, SearchResolver, RequiredResolver, SelectOption, FieldButtonAction } from './attribute.js';
4
4
  import type { CellComponent, CellFormatter, SortableModule, TableQuery } from './table.js';
5
5
  import type { XlsxModule } from '../components/table/export.js';
6
6
  export type ColumnDefinition<T extends object = Record<string, unknown>> = {
@@ -46,6 +46,8 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
46
46
  minLength?: number;
47
47
  maxLength?: number;
48
48
  pattern?: string;
49
+ /** See `AttributeMetadata.validate`. */
50
+ validate?: (value: unknown, record: Record<string, unknown>) => string | undefined;
49
51
  /** Textarea fields only: the HTML `rows` attribute, controlling height. */
50
52
  rows?: number;
51
53
  /** Embedded fields only: sub-field definitions for each item, built from
@@ -57,6 +59,8 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
57
59
  defaultExpanded?: boolean;
58
60
  /** Fields sharing the same `row` string render side by side. */
59
61
  row?: string;
62
+ /** See `AttributeMetadata.actions`. */
63
+ actions?: FieldButtonAction[];
60
64
  }
61
65
  /** A create-form button whose visibility, label and styling can all be
62
66
  * overridden — `enabled` falls back to the button's own default (see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.56",
3
+ "version": "0.0.58",
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",