runeforge 0.0.25 → 0.0.27

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
@@ -36,6 +36,8 @@ A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from
36
36
  - [Field grouping](#field-grouping)
37
37
  - [Default values](#default-values)
38
38
  - [Select options](#select-options)
39
+ - [Multiselect fields](#multiselect-fields)
40
+ - [Tree fields](#tree-fields)
39
41
  - [Embedded fields (sub-documents)](#embedded-fields-sub-documents)
40
42
  - [Components](#components)
41
43
  - [GenericCRUD](#genericcrud)
@@ -91,10 +93,11 @@ Runeforge provides a set of composable, metadata-driven components for building
91
93
 
92
94
  - **GenericCRUD** — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
93
95
  - **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
94
- - **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, datetime, and embedded (sub-document list) types.
96
+ - **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, multiselect, tree, datetime, and embedded (sub-document list) types.
95
97
  - **Validation** — built-in `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` rules, checked client-side before submit with consistent, translatable error messages.
96
- - **Conditional fields & field grouping** — disable a field based on the current values of others in the same form, and visually group related fields under a titled `fieldset`.
97
- - **Smart select fields** — options can be static, computed from page data, dependent on another field's value, or resolved live from the server as the user types.
98
+ - **Conditional fields & field grouping** — disable, or entirely hide, a field based on the current values of others in the same form, and visually group related fields under a titled `fieldset`.
99
+ - **Smart select fields** — options can be static, computed from page data, dependent on another field's value, or resolved live from the server as the user types. `multiselect` supports the same resolvers for a checkbox-style multiple-choice list.
100
+ - **Tree fields** — a hierarchical, cascading-selection picker (e.g. categories with parent/child relationships) driven by a flat option list with a `parentValue` link.
98
101
  - **Embedded fields** — model one-to-many sub-documents (e.g. line items, adjustments) as an in-form add/edit list backed by a single JSON field.
99
102
  - **Custom row & bulk actions** — add entity-specific actions (in a panel or via redirect) alongside the built-in view/edit/delete, and bulk actions that operate on the current selection.
100
103
  - **CSV/XLSX export** — one-click export of the current table view, with optional Excel support via the `xlsx` package.
@@ -347,20 +350,21 @@ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a
347
350
  | Option | Type | Applies to | Description |
348
351
  | --- | --- | --- | --- |
349
352
  | `label` | `string` | all | Column header, form label, and the field name used in validation messages |
350
- | `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `datetime` \| `embedded` |
353
+ | `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `multiselect` \| `tree` \| `datetime` \| `embedded` |
351
354
  | `required` | `boolean \| (record) => boolean` | all | Marks the label and enforces a non-empty value on submit. The function form re-evaluates against the other fields' current values — see [Validation](#validation) |
352
355
  | `autocomplete` | `FullAutoFill` | text-like | Native `autocomplete` attribute |
353
- | `placeholder` | `string` | text-like, select | Placeholder text |
356
+ | `placeholder` | `string` | text-like, select, multiselect | Placeholder text |
354
357
  | `default` | `value \| (data) => value` | all | Initial value on the create form — see [Default values](#default-values) |
355
358
  | `min` / `max` | `number` | `number` | Numeric range validation |
356
359
  | `integer` | `boolean` | `number` | Rejects non-whole numbers |
357
360
  | `minLength` / `maxLength` | `number` | text-like | Character-count validation |
358
361
  | `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
359
362
  | `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
363
+ | `hidden` | `boolean \| (record) => boolean` | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see [Conditional fields](#conditional-fields) |
360
364
  | `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
361
- | `options` | `SelectOption[] \| (data) => SelectOption[]` | `select` | Static or computed option list — see [Select options](#select-options) |
362
- | `dependentOptions` | `(data, record) => SelectOption[]` | `select` | Options derived from other fields' current values |
363
- | `search` | `(query) => Promise<SelectOption[]>` | `select` | Server-side option search as the user types |
365
+ | `options` | `SelectOption[] \| (data) => SelectOption[]` | `select`, `multiselect`, `tree` | Static or computed option list — see [Select options](#select-options). `tree` options additionally accept `parentValue` — see [Tree fields](#tree-fields) |
366
+ | `dependentOptions` | `(data, record) => SelectOption[]` | `select`, `multiselect`, `tree` | Options derived from other fields' current values |
367
+ | `search` | `(query) => Promise<SelectOption[]>` | `select`, `multiselect` | Server-side option search as the user types |
364
368
  | `seed` | `(instance) => unknown` | all | Overrides how the update form seeds this field from the loaded record |
365
369
  | `fields` | `InterfaceMetadata<any>` | `embedded` | Sub-field schema for each item — see [Embedded fields](#embedded-fields-sub-documents) |
366
370
  | `itemLabel` | `(item) => string` | `embedded` | Summary label for an item in the embedded list |
@@ -437,6 +441,33 @@ quantity: {
437
441
  },
438
442
  ```
439
443
 
444
+ `hidden` follows the exact same `boolean | (record) => boolean` shape, but goes a step further than `disabled`: a hidden field isn't just greyed out, it's removed from the form entirely — not rendered, not required-checked, not submitted. Use it when a field only makes sense for certain values of another field, rather than merely being non-editable:
445
+
446
+ ```ts
447
+ paymentMethod: {
448
+ label: 'Payment method',
449
+ type: AttributeType.select,
450
+ options: [
451
+ { value: 'card', label: 'Credit card' },
452
+ { value: 'cash', label: 'Cash on delivery' },
453
+ ],
454
+ },
455
+ cardNumber: {
456
+ label: 'Card number',
457
+ type: AttributeType.text,
458
+ hidden: (record) => record.paymentMethod !== 'card',
459
+ required: (record) => record.paymentMethod === 'card',
460
+ },
461
+ cardExpiry: {
462
+ label: 'Expiry date',
463
+ type: AttributeType.text,
464
+ hidden: (record) => record.paymentMethod !== 'card',
465
+ required: (record) => record.paymentMethod === 'card',
466
+ },
467
+ ```
468
+
469
+ 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.
470
+
440
471
  ### Field grouping
441
472
 
442
473
  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.
@@ -524,6 +555,50 @@ export const actions: Actions = {
524
555
  };
525
556
  ```
526
557
 
558
+ ### Multiselect fields
559
+
560
+ `AttributeType.multiselect` is a checkbox-style multiple-choice dropdown — the same `options`/`dependentOptions`/`default`/`search` resolvers as `select` (see [Select options](#select-options)), but the stored value is a `string[]` instead of a single `string`. Picking an option toggles it without closing the dropdown, and the closed-state button summarizes the count (`"2 selected"`).
561
+
562
+ ```ts
563
+ tags: {
564
+ label: 'Tags',
565
+ type: AttributeType.multiselect,
566
+ options: [
567
+ { value: 'fragile', label: 'Fragile' },
568
+ { value: 'perishable', label: 'Perishable' },
569
+ { value: 'oversized', label: 'Oversized' },
570
+ ],
571
+ default: [],
572
+ },
573
+ ```
574
+
575
+ Like `embedded`, the value is submitted as a single hidden field holding a JSON array — parse it back out the same way:
576
+
577
+ ```ts
578
+ const tags = JSON.parse(String(data.get('tags') ?? '[]'));
579
+ ```
580
+
581
+ If the field also sets `dependentOptions`, selections that fall outside the recomputed list are pruned automatically (rather than clearing the whole field, as a single `select` does) — e.g. narrowing a `provinces` multiselect to only the options valid for the currently selected `country`.
582
+
583
+ ### Tree fields
584
+
585
+ `AttributeType.tree` is a hierarchical picker — checkboxes in a collapsible tree, where checking a parent node cascades the selection to all of its descendants. It's driven by the same flat `SelectOption[]` as `select`/`multiselect`, plus an optional `parentValue` linking each option to its parent's `value` (omit or set `null` for a root node):
586
+
587
+ ```ts
588
+ categories: {
589
+ label: 'Categories',
590
+ type: AttributeType.tree,
591
+ options: (data: { categories?: ICategory[] }) =>
592
+ (data.categories ?? []).map((c) => ({
593
+ value: String(c.id),
594
+ label: c.name,
595
+ parentValue: c.parentCategory != null ? String(c.parentCategory) : null,
596
+ })),
597
+ },
598
+ ```
599
+
600
+ The stored value is a `string[]` of selected node values, submitted the same way as `multiselect` — a single hidden field holding a JSON array, parsed back out server-side with `JSON.parse`. `dependentOptions` and `hidden` work the same as any other field type.
601
+
527
602
  ### Embedded fields (sub-documents)
528
603
 
529
604
  `AttributeType.embedded` models a one-to-many list of sub-records — line items, adjustments, contacts, anything you'd otherwise store as an array of objects — entirely within one form field. It renders as a list with an "+ Add" button; each item is added/edited through a modal built from the `fields` sub-schema, and removed with a single click. The whole list is serialized to JSON and submitted as a single hidden form field.
@@ -3,6 +3,8 @@
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 MultiSelect from '../form/MultiSelect.svelte';
7
+ import Tree from '../form/Tree.svelte';
6
8
  import EmbeddedField from './EmbeddedField.svelte';
7
9
  import { fieldLabel, initials } from './utils/misc.js';
8
10
  import type { FieldDefinition } from '../../types/crud.js';
@@ -47,16 +49,30 @@
47
49
  const selectOptions = $derived(field.dependentOptions ? field.dependentOptions(record) : (field.options ?? []));
48
50
  const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
49
51
  const fieldRequired = $derived(typeof field.required === 'function' ? field.required(record) : !!field.required);
52
+ const fieldHidden = $derived(typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden);
53
+ const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
50
54
 
51
55
  $effect(() => {
52
- if (!field.dependentOptions) return;
56
+ if (!field.dependentOptions || isMultiValued) return;
53
57
  const current = record[field.attribute];
54
58
  if (current && !selectOptions.some((o) => o.value === String(current))) {
55
59
  record[field.attribute] = '';
56
60
  }
57
61
  });
62
+
63
+ $effect(() => {
64
+ if (!field.dependentOptions || !isMultiValued) return;
65
+ const current = record[field.attribute];
66
+ if (!Array.isArray(current)) return;
67
+ const validValues = new Set(selectOptions.map((o) => o.value));
68
+ const pruned = current.filter((v) => validValues.has(String(v)));
69
+ if (pruned.length !== current.length) {
70
+ record[field.attribute] = pruned;
71
+ }
72
+ });
58
73
  </script>
59
74
 
75
+ {#if !fieldHidden}
60
76
  <div class="flex flex-col gap-1">
61
77
  {#if field.type === 'file'}
62
78
  <div class="flex justify-center">
@@ -151,6 +167,43 @@
151
167
  {/if}
152
168
  {:else if field.type === 'embedded'}
153
169
  <EmbeddedField {field} bind:record {readonly} />
170
+ {:else if field.type === 'multiselect'}
171
+ {#if readonly}
172
+ <input
173
+ type="text"
174
+ id={field.attribute}
175
+ class="input input-bordered w-full"
176
+ value={(Array.isArray(saved) ? saved : []).map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v)).join(', ')}
177
+ disabled
178
+ />
179
+ {:else}
180
+ <MultiSelect
181
+ name={field.attribute}
182
+ bind:value={record[field.attribute] as string[]}
183
+ options={selectOptions}
184
+ search={field.search}
185
+ placeholder={field.placeholder}
186
+ disabled={fieldDisabled}
187
+ {error}
188
+ />
189
+ {/if}
190
+ {:else if field.type === 'tree'}
191
+ {#if readonly}
192
+ <input
193
+ type="text"
194
+ id={field.attribute}
195
+ class="input input-bordered w-full"
196
+ value={(Array.isArray(saved) ? saved : []).map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v)).join(', ')}
197
+ disabled
198
+ />
199
+ {:else}
200
+ <Tree
201
+ name={field.attribute}
202
+ bind:value={record[field.attribute] as string[]}
203
+ options={selectOptions}
204
+ disabled={fieldDisabled}
205
+ />
206
+ {/if}
154
207
  {:else if readonly}
155
208
  <input
156
209
  type={field.type ?? 'text'}
@@ -178,3 +231,4 @@
178
231
  <span class="text-error text-xs">{error}</span>
179
232
  {/if}
180
233
  </div>
234
+ {/if}
@@ -11,6 +11,7 @@
11
11
  inferType,
12
12
  buildFieldDefinitions
13
13
  } from './utils/resolution.js';
14
+ import { defaultItemLabel } from './utils/embedded.js';
14
15
  import { isFilterable } from '../table/utils.js';
15
16
  import type { XlsxModule } from '../table/export.js';
16
17
  import type { AttributeMetadata } from '../../types/attribute.js';
@@ -150,15 +151,41 @@
150
151
  (meta
151
152
  ? (Object.entries(meta) as [string, AttributeMetadata][])
152
153
  .filter(([, m]) => !m.excludedFromList)
153
- .map(([k, m]) => ({
154
- attribute: k as keyof T & string,
155
- title: m.label ?? k,
156
- type: m.type,
157
- formatter: resolveFormatter(m, data),
158
- component: m.component,
159
- sortable: m.sortable,
160
- filterable: m.filterable
161
- }))
154
+ .map(([k, m]) => {
155
+ const embeddedFields =
156
+ m.type === 'embedded' && m.fields
157
+ ? buildFieldDefinitions(m.fields, data, 'excludedFromList', new Set())
158
+ : undefined;
159
+ // Arrays of objects have no sensible raw cell value, so an
160
+ // embedded column without an explicit formatter falls back to
161
+ // joining each item's label (itemLabel, or the same
162
+ // sub-field-joining summary the embedded form list uses).
163
+ const formatter =
164
+ resolveFormatter(m, data) ??
165
+ (embeddedFields
166
+ ? (value: unknown) =>
167
+ Array.isArray(value)
168
+ ? value
169
+ .map((item) =>
170
+ m.itemLabel
171
+ ? m.itemLabel(item as Record<string, unknown>)
172
+ : defaultItemLabel(embeddedFields, item as Record<string, unknown>)
173
+ )
174
+ .join(', ')
175
+ : ''
176
+ : undefined);
177
+ return {
178
+ attribute: k as keyof T & string,
179
+ title: m.label ?? k,
180
+ type: m.type,
181
+ formatter,
182
+ component: m.component,
183
+ sortable: m.sortable,
184
+ filterable: m.filterable,
185
+ fields: embeddedFields,
186
+ itemLabel: m.itemLabel
187
+ };
188
+ })
162
189
  : entityData.length > 0
163
190
  ? (Object.keys(entityData[0]) as (keyof T & string)[])
164
191
  .filter((k) => !excluded.has(k))
@@ -1,4 +1,5 @@
1
1
  import type { FieldDefinition } from '../../../types/crud.js';
2
2
  export declare function seedField<T extends object = Record<string, unknown>>(f: FieldDefinition<T>, raw: unknown): unknown;
3
3
  export declare function emptyRecord<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[]): Record<string, unknown>;
4
+ export declare function formatEmbeddedFieldValue<T extends object = Record<string, unknown>>(f: FieldDefinition<T>, raw: unknown): string;
4
5
  export declare function defaultItemLabel<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], item: Record<string, unknown>): string;
@@ -11,6 +11,12 @@ export function seedField(f, raw) {
11
11
  return raw ?? null;
12
12
  if (f.type === 'embedded')
13
13
  return Array.isArray(raw) ? raw : [];
14
+ if (f.type === 'multiselect' || f.type === 'tree')
15
+ // Selected values are matched against SelectOption.value (always a
16
+ // string), so a stored array of raw ids (e.g. numbers from JSON) must
17
+ // be stringified the same way a scalar select's value is below —
18
+ // otherwise `Set.has`/`Array.includes` silently never match.
19
+ return Array.isArray(raw) ? raw.map((v) => String(v)) : [];
14
20
  return String(raw ?? '');
15
21
  }
16
22
  // Shared by Create.svelte and EmbeddedField.svelte: both need an empty draft
@@ -20,6 +26,18 @@ export function seedField(f, raw) {
20
26
  export function emptyRecord(fields) {
21
27
  return Object.fromEntries(fields.map((f) => [f.attribute, seedField(f, f.default)]));
22
28
  }
29
+ // Shared by defaultItemLabel and the CSV/XLSX export column expansion: renders
30
+ // one sub-field's raw stored value as display text, resolving a select's
31
+ // option label instead of its stored value. Booleans are left to the caller
32
+ // since the item-label summary and an export cell want different renderings
33
+ // (omit-when-false vs. an explicit true/false column).
34
+ export function formatEmbeddedFieldValue(f, raw) {
35
+ if (raw == null || raw === '')
36
+ return '';
37
+ if (f.type === 'select')
38
+ return f.options?.find((o) => o.value === String(raw))?.label ?? String(raw);
39
+ return String(raw);
40
+ }
23
41
  // Default summary shown per item in an embedded list when the field has no
24
42
  // `itemLabel`: joins each sub-field's resolved display value so the list is
25
43
  // at least readable out of the box.
@@ -27,13 +45,9 @@ export function defaultItemLabel(fields, item) {
27
45
  return fields
28
46
  .map((f) => {
29
47
  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
48
  if (f.type === 'boolean')
35
49
  return raw ? fieldLabel(f) : null;
36
- return String(raw);
50
+ return formatEmbeddedFieldValue(f, raw) || null;
37
51
  })
38
52
  .filter((v) => !!v)
39
53
  .join(' · ');
@@ -4,4 +4,4 @@ export declare function resolveOptions(m: AttributeMetadata, d: unknown): Select
4
4
  export declare function resolveDefault(m: AttributeMetadata, d: unknown): unknown;
5
5
  export declare function resolveFormatter(m: AttributeMetadata, d: unknown): import("../../../index.ts").CellFormatter<any, any> | undefined;
6
6
  export declare function inferType(key: string, value: unknown): AttributeType;
7
- export declare function buildFieldDefinitions<T extends object = Record<string, unknown>>(meta: Partial<Record<string, AttributeMetadata>>, data: unknown, excludedFlag: 'excludedFromCreate' | 'excludedFromRead' | 'excludedFromUpdate', excluded: Set<string>): FieldDefinition<T>[];
7
+ export declare function buildFieldDefinitions<T extends object = Record<string, unknown>>(meta: Partial<Record<string, AttributeMetadata>>, data: unknown, excludedFlag: 'excludedFromCreate' | 'excludedFromRead' | 'excludedFromUpdate' | 'excludedFromList', excluded: Set<string>): FieldDefinition<T>[];
@@ -44,6 +44,7 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
44
44
  : undefined,
45
45
  search: m.search,
46
46
  disabled: m.disabled,
47
+ hidden: m.hidden,
47
48
  seed: m.seed,
48
49
  groupedAs: m.groupedAs,
49
50
  min: m.min,
@@ -0,0 +1,3 @@
1
+ import type { SelectOption } from '../../../types/attribute.js';
2
+ export declare function buildChildrenByParent(options: SelectOption[]): Map<string | null, SelectOption[]>;
3
+ export declare function collectDescendantIds(value: string, childrenByParent: Map<string | null, SelectOption[]>): string[];
@@ -0,0 +1,27 @@
1
+ // Shared by the `tree` field type (Tree.svelte) and its readonly rendering:
2
+ // groups a flat SelectOption[] by `parentValue`, so the tree can be walked
3
+ // without re-scanning the full list at every node.
4
+ export function buildChildrenByParent(options) {
5
+ const map = new Map();
6
+ for (const option of options) {
7
+ const key = option.parentValue ?? null;
8
+ const list = map.get(key) ?? [];
9
+ list.push(option);
10
+ map.set(key, list);
11
+ }
12
+ return map;
13
+ }
14
+ // Shared by Tree.svelte: when a node is checked/unchecked, every descendant
15
+ // follows the same selection state (cascading select), matching how the
16
+ // original per-app CategoryTree component behaved.
17
+ export function collectDescendantIds(value, childrenByParent) {
18
+ const ids = [];
19
+ function visit(parentValue) {
20
+ for (const child of childrenByParent.get(parentValue) ?? []) {
21
+ ids.push(child.value);
22
+ visit(child.value);
23
+ }
24
+ }
25
+ visit(value);
26
+ return ids;
27
+ }
@@ -7,8 +7,11 @@ export function validateAll(fields, formData, strings) {
7
7
  const errors = {};
8
8
  const record = Object.fromEntries(formData.entries());
9
9
  for (const field of fields) {
10
+ const hidden = typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden;
11
+ if (hidden)
12
+ continue;
10
13
  const required = typeof field.required === 'function' ? field.required(record) : !!field.required;
11
- if (field.type === 'embedded') {
14
+ if (field.type === 'embedded' || field.type === 'multiselect' || field.type === 'tree') {
12
15
  let items;
13
16
  try {
14
17
  items = JSON.parse(String(formData.get(field.attribute) ?? '[]'));
@@ -0,0 +1,160 @@
1
+ <script lang="ts">
2
+ import { SvelteMap } from 'svelte/reactivity';
3
+ import { getStrings } from '../../i18n/context.js';
4
+ import type { SearchResolver, SelectOption } from '../../types/attribute.js';
5
+
6
+ const strings = getStrings();
7
+
8
+ let {
9
+ name,
10
+ value = $bindable([]),
11
+ options = [],
12
+ search: searchFn,
13
+ searchDebounceMs = 300,
14
+ placeholder = strings.selectPlaceholder,
15
+ error = '',
16
+ disabled = false,
17
+ }: {
18
+ name?: string;
19
+ value?: string[];
20
+ options?: SelectOption[];
21
+ search?: SearchResolver;
22
+ searchDebounceMs?: number;
23
+ placeholder?: string;
24
+ error?: string;
25
+ disabled?: boolean;
26
+ } = $props();
27
+
28
+ const popId = $props.id();
29
+ const anchorName = `--multiselect-anchor-${popId}`;
30
+
31
+ let query = $state('');
32
+ let popoverEl: HTMLElement | undefined = $state();
33
+
34
+ let remoteResults = $state<SelectOption[] | null>(null);
35
+ let searching = $state(false);
36
+ const pickedLabels = new SvelteMap<string, string>();
37
+
38
+ let searchToken = 0;
39
+ $effect(() => {
40
+ if (!searchFn) return;
41
+ const q = query.trim();
42
+ if (!q) {
43
+ remoteResults = null;
44
+ searching = false;
45
+ return;
46
+ }
47
+ const token = ++searchToken;
48
+ searching = true;
49
+ const timer = setTimeout(() => {
50
+ searchFn(q).then((results) => {
51
+ if (token !== searchToken) return; // stale response, a newer query took over
52
+ remoteResults = results;
53
+ searching = false;
54
+ });
55
+ }, searchDebounceMs);
56
+ return () => clearTimeout(timer);
57
+ });
58
+
59
+ const filtered = $derived(
60
+ searchFn
61
+ ? (remoteResults ?? options)
62
+ : query.trim()
63
+ ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
64
+ : options
65
+ );
66
+
67
+ const selectedLabels = $derived(
68
+ value.map((v) => options.find((o) => o.value === v)?.label ?? pickedLabels.get(v) ?? v)
69
+ );
70
+ const buttonLabel = $derived(value.length === 0 ? placeholder : strings.selectedCount(value.length));
71
+
72
+ function toggle(option: SelectOption) {
73
+ if (value.includes(option.value)) {
74
+ value = value.filter((v) => v !== option.value);
75
+ } else {
76
+ value = [...value, option.value];
77
+ pickedLabels.set(option.value, option.label);
78
+ }
79
+ }
80
+
81
+ function clear() {
82
+ value = [];
83
+ pickedLabels.clear();
84
+ query = '';
85
+ }
86
+
87
+ function onToggle(e: Event) {
88
+ if ((e as ToggleEvent).newState === 'closed') query = '';
89
+ }
90
+ </script>
91
+
92
+ <div class="relative w-full">
93
+ {#if name}
94
+ <input type="hidden" {name} value={JSON.stringify(value)} />
95
+ {/if}
96
+
97
+ <button
98
+ type="button"
99
+ class="select select-bordered w-full text-left font-normal"
100
+ class:select-error={!!error}
101
+ class:opacity-40={value.length === 0}
102
+ {disabled}
103
+ popovertarget={popId}
104
+ style="anchor-name:{anchorName}"
105
+ title={selectedLabels.join(', ')}
106
+ >
107
+ {buttonLabel}
108
+ </button>
109
+
110
+ <div
111
+ popover="auto"
112
+ id={popId}
113
+ bind:this={popoverEl}
114
+ style="position-anchor:{anchorName}; width:anchor-size(width);"
115
+ class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
116
+ ontoggle={onToggle}
117
+ >
118
+ <div class="p-2">
119
+ <input
120
+ type="text"
121
+ class="input input-bordered input-sm w-full"
122
+ placeholder={strings.selectSearch}
123
+ bind:value={query}
124
+ autocomplete="off"
125
+ />
126
+ </div>
127
+ <ul class="max-h-48 overflow-y-auto p-1">
128
+ <li>
129
+ <button
130
+ type="button"
131
+ class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
132
+ onclick={clear}
133
+ >
134
+ {placeholder}
135
+ </button>
136
+ </li>
137
+ {#if searching}
138
+ <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
139
+ {/if}
140
+ {#each filtered as option (option.value)}
141
+ {@const checked = value.includes(option.value)}
142
+ <li>
143
+ <button
144
+ type="button"
145
+ class="flex w-full items-center gap-2 rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
146
+ class:bg-primary={checked}
147
+ class:text-primary-content={checked}
148
+ onclick={() => toggle(option)}
149
+ >
150
+ <input type="checkbox" class="checkbox checkbox-sm" {checked} tabindex="-1" readonly />
151
+ {option.label}
152
+ </button>
153
+ </li>
154
+ {/each}
155
+ {#if !searching && filtered.length === 0}
156
+ <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
157
+ {/if}
158
+ </ul>
159
+ </div>
160
+ </div>
@@ -0,0 +1,14 @@
1
+ import type { SearchResolver, SelectOption } from '../../types/attribute.js';
2
+ type $$ComponentProps = {
3
+ name?: string;
4
+ value?: string[];
5
+ options?: SelectOption[];
6
+ search?: SearchResolver;
7
+ searchDebounceMs?: number;
8
+ placeholder?: string;
9
+ error?: string;
10
+ disabled?: boolean;
11
+ };
12
+ declare const MultiSelect: import("svelte").Component<$$ComponentProps, {}, "value">;
13
+ type MultiSelect = ReturnType<typeof MultiSelect>;
14
+ export default MultiSelect;
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import type { SelectOption } from '../../types/attribute.js';
3
+ import { buildChildrenByParent, collectDescendantIds } from '../crud/utils/tree.js';
4
+ import TreeNode from './TreeNode.svelte';
5
+
6
+ let {
7
+ name,
8
+ value = $bindable([]),
9
+ options = [],
10
+ disabled = false,
11
+ columns = 1,
12
+ }: {
13
+ name?: string;
14
+ value?: string[];
15
+ options?: SelectOption[];
16
+ disabled?: boolean;
17
+ columns?: number;
18
+ } = $props();
19
+
20
+ const childrenByParent = $derived(buildChildrenByParent(options));
21
+ const selected = $derived(new Set(value));
22
+ const roots = $derived(childrenByParent.get(null) ?? []);
23
+
24
+ function toggle(nodeValue: string, checked: boolean) {
25
+ const affected = [nodeValue, ...collectDescendantIds(nodeValue, childrenByParent)];
26
+ if (checked) {
27
+ value = [...new Set([...value, ...affected])];
28
+ } else {
29
+ const affectedSet = new Set(affected);
30
+ value = value.filter((v) => !affectedSet.has(v));
31
+ }
32
+ }
33
+ </script>
34
+
35
+ <div
36
+ class="rounded-box border border-base-300 p-2"
37
+ class:opacity-50={disabled}
38
+ class:pointer-events-none={disabled}
39
+ >
40
+ {#if name}
41
+ <input type="hidden" {name} value={JSON.stringify(value)} />
42
+ {/if}
43
+ {#if roots.length === 0}
44
+ <p class="px-1 py-2 text-sm text-base-content/40">—</p>
45
+ {/if}
46
+ <div class:leaf-columns={columns > 1} style={`--columns: ${columns}`}>
47
+ {#each roots as root (root.value)}
48
+ <TreeNode node={root} {childrenByParent} {selected} onToggle={toggle} {disabled} depth={0} {columns} />
49
+ {/each}
50
+ </div>
51
+ </div>
52
+
53
+ <style>
54
+ @media (min-width: 1024px) {
55
+ .leaf-columns {
56
+ columns: var(--columns);
57
+ }
58
+ }
59
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { SelectOption } from '../../types/attribute.js';
2
+ type $$ComponentProps = {
3
+ name?: string;
4
+ value?: string[];
5
+ options?: SelectOption[];
6
+ disabled?: boolean;
7
+ columns?: number;
8
+ };
9
+ declare const Tree: import("svelte").Component<$$ComponentProps, {}, "value">;
10
+ type Tree = ReturnType<typeof Tree>;
11
+ export default Tree;
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ import type { SelectOption } from '../../types/attribute.js';
3
+ import TreeNode from './TreeNode.svelte';
4
+
5
+ let {
6
+ node,
7
+ childrenByParent,
8
+ selected,
9
+ onToggle,
10
+ disabled,
11
+ depth,
12
+ columns,
13
+ }: {
14
+ node: SelectOption;
15
+ childrenByParent: Map<string | null, SelectOption[]>;
16
+ selected: Set<string>;
17
+ onToggle: (value: string, checked: boolean) => void;
18
+ disabled: boolean;
19
+ depth: number;
20
+ columns: number;
21
+ } = $props();
22
+
23
+ let expanded = $state(true);
24
+
25
+ const children = $derived(childrenByParent.get(node.value) ?? []);
26
+ const hasChildren = $derived(children.length > 0);
27
+ </script>
28
+
29
+ <div class="flex flex-col" style={`--columns: ${columns}`}>
30
+ <div
31
+ class="flex w-full items-center gap-1 rounded py-1 pr-2 transition-colors hover:bg-base-200"
32
+ style={`padding-left: ${depth * 1.25}rem`}
33
+ >
34
+ {#if hasChildren}
35
+ <button
36
+ type="button"
37
+ class="btn btn-ghost btn-xs btn-circle"
38
+ aria-label={expanded ? 'Collapse' : 'Expand'}
39
+ onclick={() => (expanded = !expanded)}
40
+ >
41
+ <span class="inline-block text-xs transition-transform" class:rotate-90={expanded}>▸</span>
42
+ </button>
43
+ {:else}
44
+ <span class="inline-block size-6"></span>
45
+ {/if}
46
+
47
+ <input
48
+ type="checkbox"
49
+ class="checkbox checkbox-sm"
50
+ checked={selected.has(node.value)}
51
+ {disabled}
52
+ onchange={(e) => onToggle(node.value, (e.currentTarget as HTMLInputElement).checked)}
53
+ aria-label={node.label}
54
+ />
55
+
56
+ <span>{node.label}</span>
57
+ </div>
58
+
59
+ {#if expanded && hasChildren}
60
+ {#each children as child (child.value)}
61
+ <TreeNode node={child} {childrenByParent} {selected} {onToggle} {disabled} depth={depth + 1} {columns} />
62
+ {/each}
63
+ {/if}
64
+ </div>
@@ -0,0 +1,14 @@
1
+ import type { SelectOption } from '../../types/attribute.js';
2
+ import TreeNode from './TreeNode.svelte';
3
+ type $$ComponentProps = {
4
+ node: SelectOption;
5
+ childrenByParent: Map<string | null, SelectOption[]>;
6
+ selected: Set<string>;
7
+ onToggle: (value: string, checked: boolean) => void;
8
+ disabled: boolean;
9
+ depth: number;
10
+ columns: number;
11
+ };
12
+ declare const TreeNode: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type TreeNode = ReturnType<typeof TreeNode>;
14
+ export default TreeNode;
@@ -13,5 +13,9 @@ export interface XlsxModule {
13
13
  };
14
14
  writeFile: (workbook: unknown, filename: string) => void;
15
15
  }
16
+ export declare function buildTable<T extends object>(rows: T[], columns: ColumnDefinition<T>[]): {
17
+ headers: string[];
18
+ body: string[][];
19
+ };
16
20
  export declare function downloadCsv<T extends object>(rows: T[], columns: ColumnDefinition<T>[], filename: string): void;
17
21
  export declare function downloadXlsx<T extends object>(rows: T[], columns: ColumnDefinition<T>[], filename: string, xlsx: XlsxModule): void;
@@ -1,7 +1,39 @@
1
1
  import { cellRenderedText } from './utils.js';
2
- function buildTable(rows, columns) {
3
- const headers = columns.map((col) => col.title ?? col.attribute);
4
- const body = rows.map((row) => columns.map((col) => cellRenderedText(row, col)));
2
+ import { formatEmbeddedFieldValue } from '../crud/utils/embedded.js';
3
+ function isEmbeddedColumn(col) {
4
+ return col.type === 'embedded' && !!col.fields?.length;
5
+ }
6
+ // Embedded columns hold one array of sub-records per row — there's no single
7
+ // plain-text cell for that, so export (unlike the on-screen table, which
8
+ // shows a joined summary) expands each one into its own row per item and one
9
+ // sub-column per field, duplicating the row's other column values across
10
+ // them. Rows are aligned by index across embedded columns rather than
11
+ // cross-joined, since unrelated embedded lists on the same row (e.g. two
12
+ // independent one-to-many relations) have no meaningful pairing.
13
+ export function buildTable(rows, columns) {
14
+ const embeddedColumns = columns.filter(isEmbeddedColumn);
15
+ if (embeddedColumns.length === 0) {
16
+ const headers = columns.map((col) => col.title ?? col.attribute);
17
+ const body = rows.map((row) => columns.map((col) => cellRenderedText(row, col)));
18
+ return { headers, body };
19
+ }
20
+ const headers = columns.flatMap((col) => isEmbeddedColumn(col)
21
+ ? col.fields.map((f) => `${col.title ?? col.attribute} - ${f.title ?? f.attribute}`)
22
+ : [col.title ?? col.attribute]);
23
+ const body = [];
24
+ for (const row of rows) {
25
+ const itemCount = Math.max(1, ...embeddedColumns.map((col) => row[col.attribute]?.length ?? 0));
26
+ for (let i = 0; i < itemCount; i++) {
27
+ const line = columns.flatMap((col) => {
28
+ if (!isEmbeddedColumn(col))
29
+ return [cellRenderedText(row, col)];
30
+ const items = row[col.attribute] ?? [];
31
+ const item = items[i];
32
+ return col.fields.map((f) => (item ? formatEmbeddedFieldValue(f, item[f.attribute]) : ''));
33
+ });
34
+ body.push(line);
35
+ }
36
+ }
5
37
  return { headers, body };
6
38
  }
7
39
  function escapeCsvCell(value) {
package/dist/i18n/en.js CHANGED
@@ -12,6 +12,7 @@ export const en = {
12
12
  selectSearch: 'Search...',
13
13
  selectSearching: 'Searching...',
14
14
  selectNoResults: 'No results',
15
+ selectedCount: (count) => `${count} selected`,
15
16
  view: 'View',
16
17
  edit: 'Edit',
17
18
  delete: 'Delete',
package/dist/i18n/es.js CHANGED
@@ -12,6 +12,7 @@ export const es = {
12
12
  selectSearch: 'Buscar...',
13
13
  selectSearching: 'Buscando...',
14
14
  selectNoResults: 'Sin resultados',
15
+ selectedCount: (count) => `${count} seleccionado${count === 1 ? '' : 's'}`,
15
16
  view: 'Ver',
16
17
  edit: 'Editar',
17
18
  delete: 'Eliminar',
@@ -12,6 +12,7 @@ export interface RuneforgeStrings {
12
12
  selectSearch: string;
13
13
  selectSearching: string;
14
14
  selectNoResults: string;
15
+ selectedCount: (count: number) => string;
15
16
  view: string;
16
17
  edit: string;
17
18
  delete: string;
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export { default as Button } from './components/form/Button.svelte';
17
17
  export { default as Label } from './components/form/Label.svelte';
18
18
  export { default as Required } from './components/form/Required.svelte';
19
19
  export { default as Select } from './components/form/Select.svelte';
20
+ export { default as MultiSelect } from './components/form/MultiSelect.svelte';
21
+ export { default as Tree } from './components/form/Tree.svelte';
20
22
  export { default as PasswordInput } from './components/form/PasswordInput.svelte';
21
23
  export { default as Avatar } from './components/Avatar.svelte';
22
24
  export { default as Modal } from './components/Modal.svelte';
@@ -49,3 +51,4 @@ export { groupFields } from './components/crud/utils/grouping.js';
49
51
  export type { FieldGroup } from './components/crud/utils/grouping.js';
50
52
  export { validateAll } from './components/crud/utils/validation.js';
51
53
  export { emptyRecord, defaultItemLabel } from './components/crud/utils/embedded.js';
54
+ export { buildChildrenByParent, collectDescendantIds } from './components/crud/utils/tree.js';
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ export { default as Button } from './components/form/Button.svelte';
11
11
  export { default as Label } from './components/form/Label.svelte';
12
12
  export { default as Required } from './components/form/Required.svelte';
13
13
  export { default as Select } from './components/form/Select.svelte';
14
+ export { default as MultiSelect } from './components/form/MultiSelect.svelte';
15
+ export { default as Tree } from './components/form/Tree.svelte';
14
16
  export { default as PasswordInput } from './components/form/PasswordInput.svelte';
15
17
  // Shared components
16
18
  export { default as Avatar } from './components/Avatar.svelte';
@@ -46,3 +48,4 @@ export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance }
46
48
  export { groupFields } from './components/crud/utils/grouping.js';
47
49
  export { validateAll } from './components/crud/utils/validation.js';
48
50
  export { emptyRecord, defaultItemLabel } from './components/crud/utils/embedded.js';
51
+ export { buildChildrenByParent, collectDescendantIds } from './components/crud/utils/tree.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' | 'embedded';
3
+ export type AttributeType = 'text' | 'email' | 'password' | 'number' | 'boolean' | 'textarea' | 'file' | 'select' | 'multiselect' | 'tree' | 'datetime' | 'embedded';
4
4
  export declare const AttributeType: {
5
5
  readonly text: "text";
6
6
  readonly email: "email";
@@ -10,19 +10,25 @@ export declare const AttributeType: {
10
10
  readonly textarea: "textarea";
11
11
  readonly file: "file";
12
12
  readonly select: "select";
13
+ readonly multiselect: "multiselect";
14
+ readonly tree: "tree";
13
15
  readonly datetime: "datetime";
14
16
  readonly embedded: "embedded";
15
17
  };
16
18
  export type InterfaceMetadata<T> = Partial<Record<keyof T, AttributeMetadata>>;
19
+ /** `parentValue` is only used by `tree` fields, to link a node to its parent's
20
+ * `value` (or omit/`null` for a root node) — `select`/`multiselect` ignore it. */
17
21
  export type SelectOption = {
18
22
  value: string;
19
23
  label: string;
24
+ parentValue?: string | null;
20
25
  };
21
26
  export type OptionsResolver = SelectOption[] | ((data: any) => SelectOption[]);
22
27
  export type FormatterResolver = (data?: any) => CellFormatter<any, any>;
23
28
  export type DependentOptionsResolver = (data: any, record: Record<string, unknown>) => SelectOption[];
24
29
  export type SearchResolver = (query: string) => Promise<SelectOption[]>;
25
30
  export type DisabledResolver = (record: Record<string, unknown>) => boolean;
31
+ export type HiddenResolver = (record: Record<string, unknown>) => boolean;
26
32
  export type RequiredResolver = (record: Record<string, unknown>) => boolean;
27
33
  export type SeedResolver = (instance: any) => unknown;
28
34
  /** Embedded fields only: renders a short summary for one item in the list.
@@ -38,6 +44,11 @@ export type AttributeMetadata = {
38
44
  * list in memory. Leave unset to keep the default in-memory filtering. */
39
45
  search?: SearchResolver;
40
46
  disabled?: DisabledResolver;
47
+ /** Pass a function to remove a field from the form entirely (not rendered,
48
+ * not validated, not submitted) based on the current draft record — unlike
49
+ * `disabled`, which keeps the input visible but greyed out. Re-evaluated
50
+ * live as sibling fields change, same signature as `disabled`. */
51
+ hidden?: boolean | HiddenResolver;
41
52
  seed?: SeedResolver;
42
53
  component?: CellComponent<any, any>;
43
54
  formatter?: FormatterResolver;
@@ -7,6 +7,8 @@ export const AttributeType = {
7
7
  textarea: 'textarea',
8
8
  file: 'file',
9
9
  select: 'select',
10
+ multiselect: 'multiselect',
11
+ tree: 'tree',
10
12
  datetime: 'datetime',
11
13
  embedded: 'embedded',
12
14
  };
@@ -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 } from './attribute.js';
3
+ import type { AttributeType, SearchResolver, RequiredResolver, SelectOption } from './attribute.js';
4
4
  import type { CellComponent, CellFormatter } from './table.js';
5
5
  export type ColumnDefinition<T extends object = Record<string, unknown>> = {
6
6
  [K in keyof T & string]: {
@@ -11,6 +11,13 @@ export type ColumnDefinition<T extends object = Record<string, unknown>> = {
11
11
  formatter?: CellFormatter<T, T[K]>;
12
12
  sortable?: boolean;
13
13
  filterable?: boolean;
14
+ /** Embedded columns only: sub-field definitions for each item, used to
15
+ * render a default cell summary and to expand the column into one
16
+ * sub-column per field on CSV/XLSX export. */
17
+ fields?: FieldDefinition<Record<string, unknown>>[];
18
+ /** Embedded columns only: short label for an item, reused from the form
19
+ * field's `itemLabel` for the default cell summary. */
20
+ itemLabel?: (item: Record<string, unknown>) => string;
14
21
  };
15
22
  }[keyof T & string];
16
23
  export interface FieldDefinition<T extends object = Record<string, unknown>> {
@@ -21,16 +28,11 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
21
28
  autocomplete?: FullAutoFill;
22
29
  placeholder?: string;
23
30
  default?: any;
24
- options?: {
25
- value: string;
26
- label: string;
27
- }[];
28
- dependentOptions?: (record: Record<string, unknown>) => {
29
- value: string;
30
- label: string;
31
- }[];
31
+ options?: SelectOption[];
32
+ dependentOptions?: (record: Record<string, unknown>) => SelectOption[];
32
33
  search?: SearchResolver;
33
34
  disabled?: (record: Record<string, unknown>) => boolean;
35
+ hidden?: boolean | ((record: Record<string, unknown>) => boolean);
34
36
  seed?: (instance: any) => unknown;
35
37
  groupedAs?: string;
36
38
  min?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.25",
3
+ "version": "0.0.27",
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",