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.
@@ -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={field.required && !readonly}
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 (field.required && !val) {
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
- function emptyRecord(): Record<string, unknown> {
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
- if (f.type !== 'boolean' && f.type !== 'file') {
51
- const raw = f.seed ? f.seed(inst) : inst[f.attribute];
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
- let open = $state(false);
27
+ const popId = $props.id();
28
+ const anchorName = `--select-anchor-${popId}`;
29
+
28
30
  let query = $state('');
29
- let container: HTMLDivElement;
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
- function onWindowClick(e: MouseEvent) {
95
- if (open && !container.contains(e.target as Node)) {
96
- open = false;
97
- query = '';
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
- <svelte:window onclick={onWindowClick} />
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
- onclick={toggle}
108
+ popovertarget={popId}
109
+ style="anchor-name:{anchorName}"
116
110
  >
117
111
  {selectedLabel}
118
112
  </button>
119
113
 
120
- {#if open}
121
- <div class="absolute z-50 mt-1 w-full rounded-box border border-base-content/10 bg-base-100 shadow-lg">
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">
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 text-base-content/40 hover:bg-base-200"
136
- onclick={clear}
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
- {placeholder}
153
+ {option.label}
139
154
  </button>
140
155
  </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)}
145
- <li>
146
- <button
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`,
@@ -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
- required?: boolean;
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
  };
@@ -8,4 +8,5 @@ export const AttributeType = {
8
8
  file: 'file',
9
9
  select: 'select',
10
10
  datetime: 'datetime',
11
+ embedded: 'embedded',
11
12
  };