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 +69 -5
- package/dist/components/IconRenderer.svelte +56 -5
- package/dist/components/IconRenderer.svelte.d.ts +2 -0
- package/dist/components/crud/Field.svelte +34 -6
- package/dist/components/crud/utils/resolution.js +3 -1
- package/dist/components/crud/utils/validation.js +10 -0
- package/dist/components/crud/views/Create.svelte +35 -29
- package/dist/components/crud/views/Read.svelte +13 -7
- package/dist/components/crud/views/Update.svelte +26 -20
- package/dist/icons/context.d.ts +5 -0
- package/dist/icons/context.js +10 -0
- package/dist/icons/sets/bootstrap.js +1 -4
- package/dist/icons/types.d.ts +0 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/attribute.d.ts +31 -0
- package/dist/types/crud.d.ts +5 -1
- package/package.json +1 -1
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
|
|
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
|
|
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,
|
|
1343
|
+
import { setIconSet, bootstrapIconSet } from 'runeforge';
|
|
1282
1344
|
|
|
1283
|
-
setIconSet(
|
|
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 {
|
|
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
|
|
15
|
-
const
|
|
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
|
|
19
|
-
|
|
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,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
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
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
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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>
|
package/dist/icons/context.d.ts
CHANGED
|
@@ -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;
|
package/dist/icons/context.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/dist/icons/types.d.ts
CHANGED
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
|
};
|
package/dist/types/crud.d.ts
CHANGED
|
@@ -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
|