runeforge 0.0.57 → 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.
@@ -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>
@@ -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.57",
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",