runeforge 0.0.23 → 0.0.25
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 +469 -21
- package/dist/components/Modal.svelte +26 -1
- package/dist/components/Modal.svelte.d.ts +9 -0
- package/dist/components/crud/EmbeddedField.svelte +144 -0
- package/dist/components/crud/EmbeddedField.svelte.d.ts +29 -0
- package/dist/components/crud/Field.svelte +5 -1
- package/dist/components/crud/utils/embedded.d.ts +4 -0
- package/dist/components/crud/utils/embedded.js +40 -0
- package/dist/components/crud/utils/resolution.js +5 -1
- package/dist/components/crud/utils/validation.js +16 -1
- package/dist/components/crud/views/Create.svelte +3 -13
- package/dist/components/crud/views/Update.svelte +3 -4
- package/dist/components/form/Select.svelte +55 -56
- package/dist/i18n/en.js +3 -0
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/types.d.ts +3 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/types/attribute.d.ts +13 -2
- package/dist/types/attribute.js +1 -0
- package/dist/types/crud.d.ts +7 -2
- package/package.json +1 -1
|
@@ -3,6 +3,15 @@ type $$ComponentProps = {
|
|
|
3
3
|
title?: string;
|
|
4
4
|
onClose?: () => void;
|
|
5
5
|
children: Snippet;
|
|
6
|
+
/** Extra classes merged onto the modal box, e.g. Tailwind size utilities
|
|
7
|
+
* like `max-w-4xl` or `w-11/12`. */
|
|
8
|
+
class?: string;
|
|
9
|
+
/** Explicit size overrides (any valid CSS length, e.g. '600px', '90vw').
|
|
10
|
+
* Applied as inline styles, so they take priority over `class` utilities. */
|
|
11
|
+
width?: string;
|
|
12
|
+
maxWidth?: string;
|
|
13
|
+
height?: string;
|
|
14
|
+
maxHeight?: string;
|
|
6
15
|
};
|
|
7
16
|
declare const Modal: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
17
|
type Modal = ReturnType<typeof Modal>;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
+
import Field from './Field.svelte';
|
|
3
|
+
import Button from '../form/Button.svelte';
|
|
4
|
+
import Modal from '../Modal.svelte';
|
|
5
|
+
import { emptyRecord, seedField, defaultItemLabel } from './utils/embedded.js';
|
|
6
|
+
import { fieldLabel } from './utils/misc.js';
|
|
7
|
+
import { validateAll } from './utils/validation.js';
|
|
8
|
+
import type { FieldDefinition } from '../../types/crud.js';
|
|
9
|
+
import { getStrings } from '../../i18n/context.js';
|
|
10
|
+
|
|
11
|
+
const strings = getStrings();
|
|
12
|
+
|
|
13
|
+
let {
|
|
14
|
+
field,
|
|
15
|
+
record = $bindable({} as Record<string, unknown>),
|
|
16
|
+
readonly = false
|
|
17
|
+
}: {
|
|
18
|
+
field: FieldDefinition<T>;
|
|
19
|
+
record?: Record<string, unknown>;
|
|
20
|
+
readonly?: boolean;
|
|
21
|
+
} = $props();
|
|
22
|
+
|
|
23
|
+
const subFields = $derived(field.fields ?? []);
|
|
24
|
+
const items = $derived((record[field.attribute] as Record<string, unknown>[] | undefined) ?? []);
|
|
25
|
+
|
|
26
|
+
let modalOpen = $state(false);
|
|
27
|
+
// null while adding a new item; the item's index while editing an existing
|
|
28
|
+
// one, so saveItem knows whether to append or replace in place.
|
|
29
|
+
let editingIndex = $state<number | null>(null);
|
|
30
|
+
let draft = $state<Record<string, unknown>>({});
|
|
31
|
+
let draftErrors = $state<Record<string, string>>({});
|
|
32
|
+
|
|
33
|
+
function openCreateModal() {
|
|
34
|
+
editingIndex = null;
|
|
35
|
+
draft = emptyRecord(subFields);
|
|
36
|
+
draftErrors = {};
|
|
37
|
+
modalOpen = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function openEditModal(index: number) {
|
|
41
|
+
const item = items[index];
|
|
42
|
+
editingIndex = index;
|
|
43
|
+
draft = Object.fromEntries(
|
|
44
|
+
subFields.map((f) => [f.attribute, seedField(f, f.seed ? f.seed(item) : item[f.attribute])])
|
|
45
|
+
);
|
|
46
|
+
draftErrors = {};
|
|
47
|
+
modalOpen = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function closeModal() {
|
|
51
|
+
modalOpen = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function coerce(f: FieldDefinition, raw: unknown): unknown {
|
|
55
|
+
if (f.type === 'number') return raw === '' || raw == null ? null : Number(raw);
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function saveItem() {
|
|
60
|
+
const fd = new FormData();
|
|
61
|
+
for (const f of subFields) fd.set(f.attribute, String(draft[f.attribute] ?? ''));
|
|
62
|
+
const errs = validateAll(subFields, fd, strings);
|
|
63
|
+
if (Object.keys(errs).length > 0) {
|
|
64
|
+
draftErrors = errs;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const item = Object.fromEntries(
|
|
68
|
+
subFields.map((f) => [f.attribute, coerce(f, draft[f.attribute])])
|
|
69
|
+
);
|
|
70
|
+
record[field.attribute] =
|
|
71
|
+
editingIndex == null
|
|
72
|
+
? [...items, item]
|
|
73
|
+
: items.map((existing, i) => (i === editingIndex ? item : existing));
|
|
74
|
+
modalOpen = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function removeItem(index: number) {
|
|
78
|
+
record[field.attribute] = items.filter((_, i) => i !== index);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function itemLabel(item: Record<string, unknown>): string {
|
|
82
|
+
return field.itemLabel ? field.itemLabel(item) : defaultItemLabel(subFields, item);
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<div class="flex flex-col gap-2">
|
|
87
|
+
{#if !readonly}
|
|
88
|
+
<input type="hidden" name={field.attribute} value={JSON.stringify(items)} />
|
|
89
|
+
{/if}
|
|
90
|
+
|
|
91
|
+
{#if items.length === 0}
|
|
92
|
+
<p class="text-sm text-base-content/50">{strings.noItems}</p>
|
|
93
|
+
{:else}
|
|
94
|
+
<ul class="flex flex-col gap-1">
|
|
95
|
+
{#each items as item, i (i)}
|
|
96
|
+
<li
|
|
97
|
+
class="flex items-center justify-between gap-2 rounded-box border border-base-300 px-3 py-2 text-sm"
|
|
98
|
+
>
|
|
99
|
+
{#if readonly}
|
|
100
|
+
<span>{itemLabel(item)}</span>
|
|
101
|
+
{:else}
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
class="flex-1 text-left hover:underline"
|
|
105
|
+
onclick={() => openEditModal(i)}
|
|
106
|
+
>
|
|
107
|
+
{itemLabel(item)}
|
|
108
|
+
</button>
|
|
109
|
+
<Button
|
|
110
|
+
variant="ghost"
|
|
111
|
+
class="btn-xs btn-circle"
|
|
112
|
+
aria-label={strings.remove}
|
|
113
|
+
onclick={() => removeItem(i)}
|
|
114
|
+
>
|
|
115
|
+
✕
|
|
116
|
+
</Button>
|
|
117
|
+
{/if}
|
|
118
|
+
</li>
|
|
119
|
+
{/each}
|
|
120
|
+
</ul>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
{#if !readonly}
|
|
124
|
+
<Button variant="outline" class="btn-sm self-start" onclick={openCreateModal}>
|
|
125
|
+
+ {strings.add}
|
|
126
|
+
</Button>
|
|
127
|
+
{/if}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{#if modalOpen}
|
|
131
|
+
<Modal title={fieldLabel(field)} onClose={closeModal}>
|
|
132
|
+
<div class="flex flex-col gap-4">
|
|
133
|
+
{#each subFields as f (f.attribute)}
|
|
134
|
+
<Field field={f} bind:record={draft} error={draftErrors[f.attribute] ?? ''} />
|
|
135
|
+
{/each}
|
|
136
|
+
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
137
|
+
<Button variant="ghost" onclick={closeModal}>{strings.cancel}</Button>
|
|
138
|
+
<Button variant="primary" onclick={saveItem}>
|
|
139
|
+
{editingIndex == null ? strings.add : strings.edit}
|
|
140
|
+
</Button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
</Modal>
|
|
144
|
+
{/if}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FieldDefinition } from '../../types/crud.js';
|
|
2
|
+
declare function $$render<T extends object = Record<string, unknown>>(): {
|
|
3
|
+
props: {
|
|
4
|
+
field: FieldDefinition<T>;
|
|
5
|
+
record?: Record<string, unknown>;
|
|
6
|
+
readonly?: boolean;
|
|
7
|
+
};
|
|
8
|
+
exports: {};
|
|
9
|
+
bindings: "record";
|
|
10
|
+
slots: {};
|
|
11
|
+
events: {};
|
|
12
|
+
};
|
|
13
|
+
declare class __sveltets_Render<T extends object = Record<string, unknown>> {
|
|
14
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
15
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
16
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
17
|
+
bindings(): "record";
|
|
18
|
+
exports(): {};
|
|
19
|
+
}
|
|
20
|
+
interface $$IsomorphicComponent {
|
|
21
|
+
new <T extends object = Record<string, unknown>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
22
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
23
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
24
|
+
<T extends object = Record<string, unknown>>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
25
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
26
|
+
}
|
|
27
|
+
declare const EmbeddedField: $$IsomorphicComponent;
|
|
28
|
+
type EmbeddedField<T extends object = Record<string, unknown>> = InstanceType<typeof EmbeddedField<T>>;
|
|
29
|
+
export default EmbeddedField;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import Avatar from '../Avatar.svelte';
|
|
4
4
|
import Label from '../form/Label.svelte';
|
|
5
5
|
import Select from '../form/Select.svelte';
|
|
6
|
+
import EmbeddedField from './EmbeddedField.svelte';
|
|
6
7
|
import { fieldLabel, initials } from './utils/misc.js';
|
|
7
8
|
import type { FieldDefinition } from '../../types/crud.js';
|
|
8
9
|
import { getStrings } from '../../i18n/context.js';
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
const displayValue = $derived(saved == null ? '' : String(saved));
|
|
46
47
|
const selectOptions = $derived(field.dependentOptions ? field.dependentOptions(record) : (field.options ?? []));
|
|
47
48
|
const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
|
|
49
|
+
const fieldRequired = $derived(typeof field.required === 'function' ? field.required(record) : !!field.required);
|
|
48
50
|
|
|
49
51
|
$effect(() => {
|
|
50
52
|
if (!field.dependentOptions) return;
|
|
@@ -66,7 +68,7 @@
|
|
|
66
68
|
text={labelText}
|
|
67
69
|
for={field.attribute}
|
|
68
70
|
capitalize={true}
|
|
69
|
-
required={
|
|
71
|
+
required={fieldRequired && !readonly}
|
|
70
72
|
/>
|
|
71
73
|
|
|
72
74
|
{#if field.type === 'boolean'}
|
|
@@ -147,6 +149,8 @@
|
|
|
147
149
|
disabled={fieldDisabled}
|
|
148
150
|
></textarea>
|
|
149
151
|
{/if}
|
|
152
|
+
{:else if field.type === 'embedded'}
|
|
153
|
+
<EmbeddedField {field} bind:record {readonly} />
|
|
150
154
|
{:else if readonly}
|
|
151
155
|
<input
|
|
152
156
|
type={field.type ?? 'text'}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FieldDefinition } from '../../../types/crud.js';
|
|
2
|
+
export declare function seedField<T extends object = Record<string, unknown>>(f: FieldDefinition<T>, raw: unknown): unknown;
|
|
3
|
+
export declare function emptyRecord<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[]): Record<string, unknown>;
|
|
4
|
+
export declare function defaultItemLabel<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], item: Record<string, unknown>): string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fieldLabel } from './misc.js';
|
|
2
|
+
// Shared by emptyRecord/Update.svelte's seedFromInstance/EmbeddedField's edit
|
|
3
|
+
// mode: converts one raw stored value into its editable draft form, using the
|
|
4
|
+
// same convention everywhere — booleans and embedded arrays keep their real
|
|
5
|
+
// type (Field.svelte's checkbox/list binds them directly), everything else
|
|
6
|
+
// becomes a string since Field.svelte's other inputs bind as strings.
|
|
7
|
+
export function seedField(f, raw) {
|
|
8
|
+
if (f.type === 'boolean')
|
|
9
|
+
return !!raw;
|
|
10
|
+
if (f.type === 'file')
|
|
11
|
+
return raw ?? null;
|
|
12
|
+
if (f.type === 'embedded')
|
|
13
|
+
return Array.isArray(raw) ? raw : [];
|
|
14
|
+
return String(raw ?? '');
|
|
15
|
+
}
|
|
16
|
+
// Shared by Create.svelte and EmbeddedField.svelte: both need an empty draft
|
|
17
|
+
// record seeded with each field's default, keyed the same way regardless of
|
|
18
|
+
// whether it ends up serialized as a top-level form or as one item inside an
|
|
19
|
+
// embedded list.
|
|
20
|
+
export function emptyRecord(fields) {
|
|
21
|
+
return Object.fromEntries(fields.map((f) => [f.attribute, seedField(f, f.default)]));
|
|
22
|
+
}
|
|
23
|
+
// Default summary shown per item in an embedded list when the field has no
|
|
24
|
+
// `itemLabel`: joins each sub-field's resolved display value so the list is
|
|
25
|
+
// at least readable out of the box.
|
|
26
|
+
export function defaultItemLabel(fields, item) {
|
|
27
|
+
return fields
|
|
28
|
+
.map((f) => {
|
|
29
|
+
const raw = item[f.attribute];
|
|
30
|
+
if (raw == null || raw === '')
|
|
31
|
+
return null;
|
|
32
|
+
if (f.type === 'select')
|
|
33
|
+
return f.options?.find((o) => o.value === String(raw))?.label ?? String(raw);
|
|
34
|
+
if (f.type === 'boolean')
|
|
35
|
+
return raw ? fieldLabel(f) : null;
|
|
36
|
+
return String(raw);
|
|
37
|
+
})
|
|
38
|
+
.filter((v) => !!v)
|
|
39
|
+
.join(' · ');
|
|
40
|
+
}
|
|
@@ -51,6 +51,10 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
|
|
|
51
51
|
integer: m.integer,
|
|
52
52
|
minLength: m.minLength,
|
|
53
53
|
maxLength: m.maxLength,
|
|
54
|
-
pattern: m.pattern
|
|
54
|
+
pattern: m.pattern,
|
|
55
|
+
fields: m.fields
|
|
56
|
+
? buildFieldDefinitions(m.fields, data, excludedFlag, new Set())
|
|
57
|
+
: undefined,
|
|
58
|
+
itemLabel: m.itemLabel
|
|
55
59
|
}));
|
|
56
60
|
}
|
|
@@ -5,9 +5,24 @@ import { fieldLabel } from './misc.js';
|
|
|
5
5
|
// styled consistently regardless of which rule failed.
|
|
6
6
|
export function validateAll(fields, formData, strings) {
|
|
7
7
|
const errors = {};
|
|
8
|
+
const record = Object.fromEntries(formData.entries());
|
|
8
9
|
for (const field of fields) {
|
|
10
|
+
const required = typeof field.required === 'function' ? field.required(record) : !!field.required;
|
|
11
|
+
if (field.type === 'embedded') {
|
|
12
|
+
let items;
|
|
13
|
+
try {
|
|
14
|
+
items = JSON.parse(String(formData.get(field.attribute) ?? '[]'));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
items = [];
|
|
18
|
+
}
|
|
19
|
+
if (required && (!Array.isArray(items) || items.length === 0)) {
|
|
20
|
+
errors[field.attribute] = strings.required(fieldLabel(field));
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
9
24
|
const val = String(formData.get(field.attribute) ?? '').trim();
|
|
10
|
-
if (
|
|
25
|
+
if (required && !val) {
|
|
11
26
|
errors[field.attribute] = strings.required(fieldLabel(field));
|
|
12
27
|
continue;
|
|
13
28
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { defaultIconSet } from '../../../icons/sets/default.js';
|
|
9
9
|
import { validateAll } from '../utils/validation.js';
|
|
10
10
|
import { groupFields } from '../utils/grouping.js';
|
|
11
|
+
import { emptyRecord } from '../utils/embedded.js';
|
|
11
12
|
import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
|
|
12
13
|
import { getStrings } from '../../../i18n/context.js';
|
|
13
14
|
|
|
@@ -41,18 +42,7 @@
|
|
|
41
42
|
let internalError = $state('');
|
|
42
43
|
let continueCreating = $state(false);
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
return Object.fromEntries(
|
|
46
|
-
fields.map((f) => [
|
|
47
|
-
f.attribute,
|
|
48
|
-
f.type === 'boolean' ? !!f.default
|
|
49
|
-
: f.type === 'file' ? (f.default ?? null)
|
|
50
|
-
: String(f.default ?? ''),
|
|
51
|
-
])
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let record = $state<Record<string, unknown>>(untrack(() => emptyRecord()));
|
|
45
|
+
let record = $state<Record<string, unknown>>(untrack(() => emptyRecord(fields)));
|
|
56
46
|
|
|
57
47
|
const groups = $derived(groupFields(fields));
|
|
58
48
|
const hasFileField = $derived(fields.some((f) => f.type === 'file'));
|
|
@@ -104,7 +94,7 @@
|
|
|
104
94
|
if (result.type === 'success' || result.type === 'redirect') {
|
|
105
95
|
await update({ reset: false });
|
|
106
96
|
if (continueCreating) {
|
|
107
|
-
record = emptyRecord();
|
|
97
|
+
record = emptyRecord(fields);
|
|
108
98
|
fieldErrors = {};
|
|
109
99
|
internalError = '';
|
|
110
100
|
} else {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { defaultIconSet } from '../../../icons/sets/default.js';
|
|
9
9
|
import { validateAll } from '../utils/validation.js';
|
|
10
10
|
import { groupFields } from '../utils/grouping.js';
|
|
11
|
+
import { seedField } from '../utils/embedded.js';
|
|
11
12
|
import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
|
|
12
13
|
import { getStrings } from '../../../i18n/context.js';
|
|
13
14
|
|
|
@@ -47,10 +48,8 @@
|
|
|
47
48
|
function seedFromInstance(inst: Record<string, unknown>): Record<string, unknown> {
|
|
48
49
|
const seeded: Record<string, unknown> = { ...inst };
|
|
49
50
|
for (const f of fields) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
seeded[f.attribute] = String(raw ?? '');
|
|
53
|
-
}
|
|
51
|
+
const raw = f.seed ? f.seed(inst) : inst[f.attribute];
|
|
52
|
+
seeded[f.attribute] = seedField(f, raw);
|
|
54
53
|
}
|
|
55
54
|
return seeded;
|
|
56
55
|
}
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
} = $props();
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const popId = $props.id();
|
|
28
|
+
const anchorName = `--select-anchor-${popId}`;
|
|
29
|
+
|
|
28
30
|
let query = $state('');
|
|
29
|
-
let
|
|
31
|
+
let popoverEl: HTMLElement | undefined = $state();
|
|
30
32
|
|
|
31
33
|
// Options resolved by `searchFn` for the current query; null while no
|
|
32
34
|
// server search has run yet (e.g. box just opened, query still empty).
|
|
@@ -71,37 +73,28 @@
|
|
|
71
73
|
: (options.find((o) => o.value === value)?.label ?? pickedLabel ?? placeholder)
|
|
72
74
|
);
|
|
73
75
|
|
|
74
|
-
function toggle() {
|
|
75
|
-
if (disabled) return;
|
|
76
|
-
open = !open;
|
|
77
|
-
if (!open) query = '';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
76
|
function pick(option: { value: string; label: string }) {
|
|
81
77
|
value = option.value;
|
|
82
78
|
pickedLabel = option.label;
|
|
83
|
-
open = false;
|
|
84
79
|
query = '';
|
|
80
|
+
popoverEl?.hidePopover();
|
|
85
81
|
}
|
|
86
82
|
|
|
87
83
|
function clear() {
|
|
88
84
|
value = '';
|
|
89
85
|
pickedLabel = null;
|
|
90
|
-
open = false;
|
|
91
86
|
query = '';
|
|
87
|
+
popoverEl?.hidePopover();
|
|
92
88
|
}
|
|
93
89
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
90
|
+
// Catches dismissal paths that don't go through pick()/clear() (outside
|
|
91
|
+
// click, Escape), so a stale search doesn't linger for next time it opens.
|
|
92
|
+
function onToggle(e: Event) {
|
|
93
|
+
if ((e as ToggleEvent).newState === 'closed') query = '';
|
|
99
94
|
}
|
|
100
95
|
</script>
|
|
101
96
|
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
<div class="relative w-full" bind:this={container}>
|
|
97
|
+
<div class="relative w-full">
|
|
105
98
|
{#if name}
|
|
106
99
|
<input type="hidden" {name} {value} />
|
|
107
100
|
{/if}
|
|
@@ -112,52 +105,58 @@
|
|
|
112
105
|
class:select-error={!!error}
|
|
113
106
|
class:opacity-40={!value}
|
|
114
107
|
{disabled}
|
|
115
|
-
|
|
108
|
+
popovertarget={popId}
|
|
109
|
+
style="anchor-name:{anchorName}"
|
|
116
110
|
>
|
|
117
111
|
{selectedLabel}
|
|
118
112
|
</button>
|
|
119
113
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
114
|
+
<div
|
|
115
|
+
popover="auto"
|
|
116
|
+
id={popId}
|
|
117
|
+
bind:this={popoverEl}
|
|
118
|
+
style="position-anchor:{anchorName}; width:anchor-size(width);"
|
|
119
|
+
class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
|
|
120
|
+
ontoggle={onToggle}
|
|
121
|
+
>
|
|
122
|
+
<div class="p-2">
|
|
123
|
+
<input
|
|
124
|
+
type="text"
|
|
125
|
+
class="input input-bordered input-sm w-full"
|
|
126
|
+
placeholder={strings.selectSearch}
|
|
127
|
+
bind:value={query}
|
|
128
|
+
autocomplete="off"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<ul class="max-h-48 overflow-y-auto p-1">
|
|
132
|
+
<li>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
|
|
136
|
+
onclick={clear}
|
|
137
|
+
>
|
|
138
|
+
{placeholder}
|
|
139
|
+
</button>
|
|
140
|
+
</li>
|
|
141
|
+
{#if searching}
|
|
142
|
+
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
|
|
143
|
+
{/if}
|
|
144
|
+
{#each filtered as option (option.value)}
|
|
132
145
|
<li>
|
|
133
146
|
<button
|
|
134
147
|
type="button"
|
|
135
|
-
class="w-full rounded-btn px-3 py-2 text-left text-sm
|
|
136
|
-
|
|
148
|
+
class="w-full rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
|
|
149
|
+
class:bg-primary={value === option.value}
|
|
150
|
+
class:text-primary-content={value === option.value}
|
|
151
|
+
onclick={() => pick(option)}
|
|
137
152
|
>
|
|
138
|
-
{
|
|
153
|
+
{option.label}
|
|
139
154
|
</button>
|
|
140
155
|
</li>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
type="button"
|
|
148
|
-
class="w-full rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
|
|
149
|
-
class:bg-primary={value === option.value}
|
|
150
|
-
class:text-primary-content={value === option.value}
|
|
151
|
-
onclick={() => pick(option)}
|
|
152
|
-
>
|
|
153
|
-
{option.label}
|
|
154
|
-
</button>
|
|
155
|
-
</li>
|
|
156
|
-
{/each}
|
|
157
|
-
{#if !searching && filtered.length === 0}
|
|
158
|
-
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
|
|
159
|
-
{/if}
|
|
160
|
-
</ul>
|
|
161
|
-
</div>
|
|
162
|
-
{/if}
|
|
156
|
+
{/each}
|
|
157
|
+
{#if !searching && filtered.length === 0}
|
|
158
|
+
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
|
|
159
|
+
{/if}
|
|
160
|
+
</ul>
|
|
161
|
+
</div>
|
|
163
162
|
</div>
|
package/dist/i18n/en.js
CHANGED
|
@@ -24,6 +24,9 @@ export const en = {
|
|
|
24
24
|
saveAndContinue: 'Save and continue',
|
|
25
25
|
cancel: 'Cancel',
|
|
26
26
|
back: 'Back',
|
|
27
|
+
add: 'Add',
|
|
28
|
+
remove: 'Remove',
|
|
29
|
+
noItems: 'No items added',
|
|
27
30
|
confirm: 'Confirm',
|
|
28
31
|
deleteConfirm: (count, actionLabel) => `Are you sure you want to ${String(actionLabel).toLowerCase()} ${count} item${count === 1 ? '' : 's'}?`,
|
|
29
32
|
required: (field) => `${field} is required`,
|
package/dist/i18n/es.js
CHANGED
|
@@ -24,6 +24,9 @@ export const es = {
|
|
|
24
24
|
saveAndContinue: 'Guardar y continuar',
|
|
25
25
|
cancel: 'Cancelar',
|
|
26
26
|
back: 'Volver',
|
|
27
|
+
add: 'Agregar',
|
|
28
|
+
remove: 'Quitar',
|
|
29
|
+
noItems: 'Sin elementos agregados',
|
|
27
30
|
confirm: 'Confirmar',
|
|
28
31
|
deleteConfirm: (count, actionLabel) => `¿Seguro que querés ${String(actionLabel).toLowerCase()} ${count} elemento${count === 1 ? '' : 's'}?`,
|
|
29
32
|
required: (field) => `${field} es requerido`,
|
package/dist/i18n/types.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface RuneforgeStrings {
|
|
|
24
24
|
saveAndContinue: string;
|
|
25
25
|
cancel: string;
|
|
26
26
|
back: string;
|
|
27
|
+
add: string;
|
|
28
|
+
remove: string;
|
|
29
|
+
noItems: string;
|
|
27
30
|
confirm: string;
|
|
28
31
|
deleteConfirm: (count: number, actionLabel: string) => string;
|
|
29
32
|
required: (field: string) => string;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { BreadcrumbItem } from './types/breadcrumb.js';
|
|
2
2
|
export { AttributeType } from './types/attribute.js';
|
|
3
|
-
export type { AttributeMetadata, InterfaceMetadata, SelectOption, OptionsResolver, FormatterResolver } from './types/attribute.js';
|
|
3
|
+
export type { AttributeMetadata, InterfaceMetadata, SelectOption, OptionsResolver, FormatterResolver, EmbeddedItemLabelResolver } from './types/attribute.js';
|
|
4
4
|
export type { CellProps, CellComponent, CellFormatter, SortDirection, IndexedRow, DistinctEntry, PaginatedEnvelope, ServerPagination, FilterSnapshot, TableQuery } from './types/table.js';
|
|
5
5
|
export type { ColumnDefinition, FieldDefinition, ActionConfiguration, CustomAction, CustomBulkAction, RowAction, SearchConfiguration } from './types/crud.js';
|
|
6
6
|
export type { RuneforgeConfig } from './config/context.js';
|
|
@@ -32,6 +32,7 @@ export { SortState, FilterState, snapshotFilter } from './components/table/state
|
|
|
32
32
|
export { cellRenderedText, isSortable, isFilterable, compare, distinctEntries } from './components/table/utils.js';
|
|
33
33
|
export { default as GenericCRUD } from './components/crud/GenericCRUD.svelte';
|
|
34
34
|
export { default as Field } from './components/crud/Field.svelte';
|
|
35
|
+
export { default as EmbeddedField } from './components/crud/EmbeddedField.svelte';
|
|
35
36
|
export { default as Header } from './components/common/Header.svelte';
|
|
36
37
|
export { default as AvatarCell } from './components/crud/columns/Avatar.svelte';
|
|
37
38
|
export { default as IconCell } from './components/crud/columns/Icon.svelte';
|
|
@@ -47,3 +48,4 @@ export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance }
|
|
|
47
48
|
export { groupFields } from './components/crud/utils/grouping.js';
|
|
48
49
|
export type { FieldGroup } from './components/crud/utils/grouping.js';
|
|
49
50
|
export { validateAll } from './components/crud/utils/validation.js';
|
|
51
|
+
export { emptyRecord, defaultItemLabel } from './components/crud/utils/embedded.js';
|
package/dist/index.js
CHANGED
|
@@ -29,6 +29,7 @@ export { cellRenderedText, isSortable, isFilterable, compare, distinctEntries }
|
|
|
29
29
|
// CRUD components
|
|
30
30
|
export { default as GenericCRUD } from './components/crud/GenericCRUD.svelte';
|
|
31
31
|
export { default as Field } from './components/crud/Field.svelte';
|
|
32
|
+
export { default as EmbeddedField } from './components/crud/EmbeddedField.svelte';
|
|
32
33
|
export { default as Header } from './components/common/Header.svelte';
|
|
33
34
|
export { default as AvatarCell } from './components/crud/columns/Avatar.svelte';
|
|
34
35
|
export { default as IconCell } from './components/crud/columns/Icon.svelte';
|
|
@@ -44,3 +45,4 @@ export { fieldLabel, initials } from './components/crud/utils/misc.js';
|
|
|
44
45
|
export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance } from './components/crud/utils/formatters.js';
|
|
45
46
|
export { groupFields } from './components/crud/utils/grouping.js';
|
|
46
47
|
export { validateAll } from './components/crud/utils/validation.js';
|
|
48
|
+
export { emptyRecord, defaultItemLabel } from './components/crud/utils/embedded.js';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FullAutoFill } from 'svelte/elements';
|
|
2
2
|
import type { CellComponent, CellFormatter } from './table.js';
|
|
3
|
-
export type AttributeType = 'text' | 'email' | 'password' | 'number' | 'boolean' | 'textarea' | 'file' | 'select' | 'datetime';
|
|
3
|
+
export type AttributeType = 'text' | 'email' | 'password' | 'number' | 'boolean' | 'textarea' | 'file' | 'select' | 'datetime' | 'embedded';
|
|
4
4
|
export declare const AttributeType: {
|
|
5
5
|
readonly text: "text";
|
|
6
6
|
readonly email: "email";
|
|
@@ -11,6 +11,7 @@ export declare const AttributeType: {
|
|
|
11
11
|
readonly file: "file";
|
|
12
12
|
readonly select: "select";
|
|
13
13
|
readonly datetime: "datetime";
|
|
14
|
+
readonly embedded: "embedded";
|
|
14
15
|
};
|
|
15
16
|
export type InterfaceMetadata<T> = Partial<Record<keyof T, AttributeMetadata>>;
|
|
16
17
|
export type SelectOption = {
|
|
@@ -22,7 +23,11 @@ export type FormatterResolver = (data?: any) => CellFormatter<any, any>;
|
|
|
22
23
|
export type DependentOptionsResolver = (data: any, record: Record<string, unknown>) => SelectOption[];
|
|
23
24
|
export type SearchResolver = (query: string) => Promise<SelectOption[]>;
|
|
24
25
|
export type DisabledResolver = (record: Record<string, unknown>) => boolean;
|
|
26
|
+
export type RequiredResolver = (record: Record<string, unknown>) => boolean;
|
|
25
27
|
export type SeedResolver = (instance: any) => unknown;
|
|
28
|
+
/** Embedded fields only: renders a short summary for one item in the list.
|
|
29
|
+
* Falls back to a dash-joined summary of the item's sub-field values. */
|
|
30
|
+
export type EmbeddedItemLabelResolver = (item: Record<string, unknown>) => string;
|
|
26
31
|
export type AttributeMetadata = {
|
|
27
32
|
label?: string;
|
|
28
33
|
type?: AttributeType;
|
|
@@ -36,7 +41,9 @@ export type AttributeMetadata = {
|
|
|
36
41
|
seed?: SeedResolver;
|
|
37
42
|
component?: CellComponent<any, any>;
|
|
38
43
|
formatter?: FormatterResolver;
|
|
39
|
-
|
|
44
|
+
/** Pass a function to require a field only in certain conditions, e.g. a
|
|
45
|
+
* quantity that only applies to some of a select's options. */
|
|
46
|
+
required?: boolean | RequiredResolver;
|
|
40
47
|
autocomplete?: FullAutoFill;
|
|
41
48
|
placeholder?: string;
|
|
42
49
|
/** Initial value on the create form. Pass a plain value, or a function of
|
|
@@ -56,4 +63,8 @@ export type AttributeMetadata = {
|
|
|
56
63
|
minLength?: number;
|
|
57
64
|
maxLength?: number;
|
|
58
65
|
pattern?: string;
|
|
66
|
+
/** Embedded fields only: schema for each item added through the "+" modal. */
|
|
67
|
+
fields?: InterfaceMetadata<any>;
|
|
68
|
+
/** Embedded fields only: short label for an item in the list. */
|
|
69
|
+
itemLabel?: EmbeddedItemLabelResolver;
|
|
59
70
|
};
|