runeforge 0.0.26 → 0.0.28
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 +108 -8
- package/dist/components/crud/Field.svelte +93 -10
- package/dist/components/crud/Field.svelte.d.ts +1 -0
- package/dist/components/crud/utils/embedded.js +6 -0
- package/dist/components/crud/utils/grouping.d.ts +2 -1
- package/dist/components/crud/utils/grouping.js +34 -12
- package/dist/components/crud/utils/resolution.js +4 -1
- package/dist/components/crud/utils/tree.d.ts +3 -0
- package/dist/components/crud/utils/tree.js +27 -0
- package/dist/components/crud/utils/validation.js +4 -1
- package/dist/components/crud/views/Create.svelte +20 -4
- package/dist/components/crud/views/Read.svelte +20 -4
- package/dist/components/crud/views/Update.svelte +20 -4
- package/dist/components/form/MultiSelect.svelte +160 -0
- package/dist/components/form/MultiSelect.svelte.d.ts +14 -0
- package/dist/components/form/Select.svelte +1 -1
- package/dist/components/form/Tree.svelte +62 -0
- package/dist/components/form/Tree.svelte.d.ts +12 -0
- package/dist/components/form/TreeNode.svelte +66 -0
- package/dist/components/form/TreeNode.svelte.d.ts +15 -0
- package/dist/components/table/ColumnFilter.svelte +1 -1
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/types.d.ts +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/types/attribute.d.ts +18 -1
- package/dist/types/attribute.js +2 -0
- package/dist/types/crud.d.ts +8 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,8 +34,11 @@ A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from
|
|
|
34
34
|
- [Validation](#validation)
|
|
35
35
|
- [Conditional fields](#conditional-fields)
|
|
36
36
|
- [Field grouping](#field-grouping)
|
|
37
|
+
- [Field rows](#field-rows)
|
|
37
38
|
- [Default values](#default-values)
|
|
38
39
|
- [Select options](#select-options)
|
|
40
|
+
- [Multiselect fields](#multiselect-fields)
|
|
41
|
+
- [Tree fields](#tree-fields)
|
|
39
42
|
- [Embedded fields (sub-documents)](#embedded-fields-sub-documents)
|
|
40
43
|
- [Components](#components)
|
|
41
44
|
- [GenericCRUD](#genericcrud)
|
|
@@ -91,10 +94,11 @@ Runeforge provides a set of composable, metadata-driven components for building
|
|
|
91
94
|
|
|
92
95
|
- **GenericCRUD** — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
|
|
93
96
|
- **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.
|
|
97
|
+
- **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
98
|
- **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.
|
|
99
|
+
- **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`.
|
|
100
|
+
- **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.
|
|
101
|
+
- **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
102
|
- **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
103
|
- **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
104
|
- **CSV/XLSX export** — one-click export of the current table view, with optional Excel support via the `xlsx` package.
|
|
@@ -156,6 +160,7 @@ Responsive overrides work too:
|
|
|
156
160
|
| `--runeforge-crud-title-size` | `1.875rem` | `<h1>` inside the `Header` component |
|
|
157
161
|
| `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
|
|
158
162
|
| `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
|
|
163
|
+
| `--runeforge-tree-max-height` | `24rem` | Max height of a `tree` field before it scrolls internally |
|
|
159
164
|
|
|
160
165
|
Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
|
|
161
166
|
|
|
@@ -347,20 +352,23 @@ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a
|
|
|
347
352
|
| Option | Type | Applies to | Description |
|
|
348
353
|
| --- | --- | --- | --- |
|
|
349
354
|
| `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` |
|
|
355
|
+
| `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `multiselect` \| `tree` \| `datetime` \| `embedded` |
|
|
351
356
|
| `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
357
|
| `autocomplete` | `FullAutoFill` | text-like | Native `autocomplete` attribute |
|
|
353
|
-
| `placeholder` | `string` | text-like, select | Placeholder text |
|
|
358
|
+
| `placeholder` | `string` | text-like, select, multiselect | Placeholder text |
|
|
354
359
|
| `default` | `value \| (data) => value` | all | Initial value on the create form — see [Default values](#default-values) |
|
|
355
360
|
| `min` / `max` | `number` | `number` | Numeric range validation |
|
|
356
361
|
| `integer` | `boolean` | `number` | Rejects non-whole numbers |
|
|
357
362
|
| `minLength` / `maxLength` | `number` | text-like | Character-count validation |
|
|
358
363
|
| `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
|
|
359
364
|
| `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
|
|
365
|
+
| `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
366
|
| `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
|
|
361
|
-
| `
|
|
362
|
-
| `
|
|
363
|
-
| `
|
|
367
|
+
| `row` | `string` | all | Renders fields sharing the same value side by side (desktop) / stacked (mobile) — see [Field rows](#field-rows) |
|
|
368
|
+
| `defaultExpanded` | `boolean` | `tree` | Whether parent nodes start expanded. Defaults to `true` |
|
|
369
|
+
| `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) |
|
|
370
|
+
| `dependentOptions` | `(data, record) => SelectOption[]` | `select`, `multiselect`, `tree` | Options derived from other fields' current values |
|
|
371
|
+
| `search` | `(query) => Promise<SelectOption[]>` | `select`, `multiselect` | Server-side option search as the user types |
|
|
364
372
|
| `seed` | `(instance) => unknown` | all | Overrides how the update form seeds this field from the loaded record |
|
|
365
373
|
| `fields` | `InterfaceMetadata<any>` | `embedded` | Sub-field schema for each item — see [Embedded fields](#embedded-fields-sub-documents) |
|
|
366
374
|
| `itemLabel` | `(item) => string` | `embedded` | Summary label for an item in the embedded list |
|
|
@@ -437,6 +445,33 @@ quantity: {
|
|
|
437
445
|
},
|
|
438
446
|
```
|
|
439
447
|
|
|
448
|
+
`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:
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
paymentMethod: {
|
|
452
|
+
label: 'Payment method',
|
|
453
|
+
type: AttributeType.select,
|
|
454
|
+
options: [
|
|
455
|
+
{ value: 'card', label: 'Credit card' },
|
|
456
|
+
{ value: 'cash', label: 'Cash on delivery' },
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
cardNumber: {
|
|
460
|
+
label: 'Card number',
|
|
461
|
+
type: AttributeType.text,
|
|
462
|
+
hidden: (record) => record.paymentMethod !== 'card',
|
|
463
|
+
required: (record) => record.paymentMethod === 'card',
|
|
464
|
+
},
|
|
465
|
+
cardExpiry: {
|
|
466
|
+
label: 'Expiry date',
|
|
467
|
+
type: AttributeType.text,
|
|
468
|
+
hidden: (record) => record.paymentMethod !== 'card',
|
|
469
|
+
required: (record) => record.paymentMethod === 'card',
|
|
470
|
+
},
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
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.
|
|
474
|
+
|
|
440
475
|
### Field grouping
|
|
441
476
|
|
|
442
477
|
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.
|
|
@@ -454,6 +489,25 @@ sku: {
|
|
|
454
489
|
},
|
|
455
490
|
```
|
|
456
491
|
|
|
492
|
+
### Field rows
|
|
493
|
+
|
|
494
|
+
Fields sharing the same `row` string render side by side on desktop and stacked on mobile, instead of each taking a full line. It's meant for small, related fields — a date range, a min/max pair — where a flat vertical stack wastes space.
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
createdFrom: {
|
|
498
|
+
label: 'Created from',
|
|
499
|
+
type: AttributeType.datetime,
|
|
500
|
+
row: 'createdRange',
|
|
501
|
+
},
|
|
502
|
+
createdTo: {
|
|
503
|
+
label: 'Created to',
|
|
504
|
+
type: AttributeType.datetime,
|
|
505
|
+
row: 'createdRange',
|
|
506
|
+
},
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
A `row` only merges fields that are also in the same `groupedAs` fieldset (or both ungrouped) — it never pulls fields together across two different fieldsets. If one of the fields in a row is conditionally [hidden](#conditional-fields), the remaining field(s) simply expand to fill the row instead of leaving a gap.
|
|
510
|
+
|
|
457
511
|
### Default values
|
|
458
512
|
|
|
459
513
|
`default` can be a plain value or a function of the page `data` object, evaluated once when the create form's fields are resolved — handy for defaulting a select to something derived from prefetched data.
|
|
@@ -524,6 +578,52 @@ export const actions: Actions = {
|
|
|
524
578
|
};
|
|
525
579
|
```
|
|
526
580
|
|
|
581
|
+
### Multiselect fields
|
|
582
|
+
|
|
583
|
+
`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"`).
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
tags: {
|
|
587
|
+
label: 'Tags',
|
|
588
|
+
type: AttributeType.multiselect,
|
|
589
|
+
options: [
|
|
590
|
+
{ value: 'fragile', label: 'Fragile' },
|
|
591
|
+
{ value: 'perishable', label: 'Perishable' },
|
|
592
|
+
{ value: 'oversized', label: 'Oversized' },
|
|
593
|
+
],
|
|
594
|
+
default: [],
|
|
595
|
+
},
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Like `embedded`, the value is submitted as a single hidden field holding a JSON array — parse it back out the same way:
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
const tags = JSON.parse(String(data.get('tags') ?? '[]'));
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
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`.
|
|
605
|
+
|
|
606
|
+
### Tree fields
|
|
607
|
+
|
|
608
|
+
`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):
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
categories: {
|
|
612
|
+
label: 'Categories',
|
|
613
|
+
type: AttributeType.tree,
|
|
614
|
+
options: (data: { categories?: ICategory[] }) =>
|
|
615
|
+
(data.categories ?? []).map((c) => ({
|
|
616
|
+
value: String(c.id),
|
|
617
|
+
label: c.name,
|
|
618
|
+
parentValue: c.parentCategory != null ? String(c.parentCategory) : null,
|
|
619
|
+
})),
|
|
620
|
+
},
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
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.
|
|
624
|
+
|
|
625
|
+
By default every parent node renders expanded; set `defaultExpanded: false` to start with the whole tree collapsed instead (the user can still expand any branch — this only sets the initial state). The field itself is capped at `--runeforge-tree-max-height` (default `24rem`, see [CSS variables](#css-variables)) and scrolls internally once its content grows past that.
|
|
626
|
+
|
|
527
627
|
### Embedded fields (sub-documents)
|
|
528
628
|
|
|
529
629
|
`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';
|
|
@@ -19,13 +21,19 @@
|
|
|
19
21
|
record = $bindable({} as Record<string, unknown>),
|
|
20
22
|
error = '',
|
|
21
23
|
readonly = false,
|
|
24
|
+
class: className = '',
|
|
22
25
|
}: {
|
|
23
26
|
field: FieldDefinition<T>;
|
|
24
27
|
record?: Record<string, unknown>;
|
|
25
28
|
error?: string;
|
|
26
29
|
readonly?: boolean;
|
|
30
|
+
class?: string;
|
|
27
31
|
} = $props();
|
|
28
32
|
|
|
33
|
+
const datePopId = $props.id();
|
|
34
|
+
const dateAnchorName = `--date-anchor-${datePopId}`;
|
|
35
|
+
let datePopoverEl: HTMLElement | undefined = $state();
|
|
36
|
+
|
|
29
37
|
const name = $derived(readonly ? undefined : field.attribute);
|
|
30
38
|
const labelText = $derived(fieldLabel(field));
|
|
31
39
|
let filePreview = $state<string | null>(null);
|
|
@@ -47,17 +55,31 @@
|
|
|
47
55
|
const selectOptions = $derived(field.dependentOptions ? field.dependentOptions(record) : (field.options ?? []));
|
|
48
56
|
const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
|
|
49
57
|
const fieldRequired = $derived(typeof field.required === 'function' ? field.required(record) : !!field.required);
|
|
58
|
+
const fieldHidden = $derived(typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden);
|
|
59
|
+
const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
|
|
50
60
|
|
|
51
61
|
$effect(() => {
|
|
52
|
-
if (!field.dependentOptions) return;
|
|
62
|
+
if (!field.dependentOptions || isMultiValued) return;
|
|
53
63
|
const current = record[field.attribute];
|
|
54
64
|
if (current && !selectOptions.some((o) => o.value === String(current))) {
|
|
55
65
|
record[field.attribute] = '';
|
|
56
66
|
}
|
|
57
67
|
});
|
|
68
|
+
|
|
69
|
+
$effect(() => {
|
|
70
|
+
if (!field.dependentOptions || !isMultiValued) return;
|
|
71
|
+
const current = record[field.attribute];
|
|
72
|
+
if (!Array.isArray(current)) return;
|
|
73
|
+
const validValues = new Set(selectOptions.map((o) => o.value));
|
|
74
|
+
const pruned = current.filter((v) => validValues.has(String(v)));
|
|
75
|
+
if (pruned.length !== current.length) {
|
|
76
|
+
record[field.attribute] = pruned;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
58
79
|
</script>
|
|
59
80
|
|
|
60
|
-
|
|
81
|
+
{#if !fieldHidden}
|
|
82
|
+
<div class="flex flex-col gap-1 {className}">
|
|
61
83
|
{#if field.type === 'file'}
|
|
62
84
|
<div class="flex justify-center">
|
|
63
85
|
<Avatar src={preview} text={avatarInitials} alt={labelText} class="w-20 rounded-full" textClass="text-xl" />
|
|
@@ -125,15 +147,37 @@
|
|
|
125
147
|
/>
|
|
126
148
|
{:else}
|
|
127
149
|
<input type="hidden" {name} value={String(record[field.attribute] ?? '')} disabled={fieldDisabled} />
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
class="input input-bordered w-full text-left font-normal"
|
|
153
|
+
class:opacity-40={!record[field.attribute]}
|
|
154
|
+
disabled={fieldDisabled}
|
|
155
|
+
popovertarget={datePopId}
|
|
156
|
+
style="anchor-name:{dateAnchorName}"
|
|
157
|
+
>
|
|
158
|
+
{record[field.attribute] ? displayValue : (field.placeholder ?? '')}
|
|
159
|
+
</button>
|
|
160
|
+
<div
|
|
161
|
+
popover="auto"
|
|
162
|
+
id={datePopId}
|
|
163
|
+
bind:this={datePopoverEl}
|
|
164
|
+
style="position-anchor:{dateAnchorName}; position-try-fallbacks:flip-block;"
|
|
165
|
+
class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
|
|
132
166
|
>
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
167
|
+
<calendar-date
|
|
168
|
+
class="cally"
|
|
169
|
+
value={String(record[field.attribute] ?? '')}
|
|
170
|
+
onchange={(e: Event) => {
|
|
171
|
+
if (fieldDisabled) return;
|
|
172
|
+
record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value;
|
|
173
|
+
datePopoverEl?.hidePopover();
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
<svg aria-label={strings.previous} class="fill-current size-4" {...{"slot": "previous"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
|
177
|
+
<svg aria-label={strings.next} class="fill-current size-4" {...{"slot": "next"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
|
|
178
|
+
<calendar-month></calendar-month>
|
|
179
|
+
</calendar-date>
|
|
180
|
+
</div>
|
|
137
181
|
{/if}
|
|
138
182
|
{:else if field.type === 'textarea'}
|
|
139
183
|
{#if readonly}
|
|
@@ -151,6 +195,44 @@
|
|
|
151
195
|
{/if}
|
|
152
196
|
{:else if field.type === 'embedded'}
|
|
153
197
|
<EmbeddedField {field} bind:record {readonly} />
|
|
198
|
+
{:else if field.type === 'multiselect'}
|
|
199
|
+
{#if readonly}
|
|
200
|
+
<input
|
|
201
|
+
type="text"
|
|
202
|
+
id={field.attribute}
|
|
203
|
+
class="input input-bordered w-full"
|
|
204
|
+
value={(Array.isArray(saved) ? saved : []).map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v)).join(', ')}
|
|
205
|
+
disabled
|
|
206
|
+
/>
|
|
207
|
+
{:else}
|
|
208
|
+
<MultiSelect
|
|
209
|
+
name={field.attribute}
|
|
210
|
+
bind:value={record[field.attribute] as string[]}
|
|
211
|
+
options={selectOptions}
|
|
212
|
+
search={field.search}
|
|
213
|
+
placeholder={field.placeholder}
|
|
214
|
+
disabled={fieldDisabled}
|
|
215
|
+
{error}
|
|
216
|
+
/>
|
|
217
|
+
{/if}
|
|
218
|
+
{:else if field.type === 'tree'}
|
|
219
|
+
{#if readonly}
|
|
220
|
+
<input
|
|
221
|
+
type="text"
|
|
222
|
+
id={field.attribute}
|
|
223
|
+
class="input input-bordered w-full"
|
|
224
|
+
value={(Array.isArray(saved) ? saved : []).map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v)).join(', ')}
|
|
225
|
+
disabled
|
|
226
|
+
/>
|
|
227
|
+
{:else}
|
|
228
|
+
<Tree
|
|
229
|
+
name={field.attribute}
|
|
230
|
+
bind:value={record[field.attribute] as string[]}
|
|
231
|
+
options={selectOptions}
|
|
232
|
+
disabled={fieldDisabled}
|
|
233
|
+
defaultExpanded={field.defaultExpanded ?? true}
|
|
234
|
+
/>
|
|
235
|
+
{/if}
|
|
154
236
|
{:else if readonly}
|
|
155
237
|
<input
|
|
156
238
|
type={field.type ?? 'text'}
|
|
@@ -178,3 +260,4 @@
|
|
|
178
260
|
<span class="text-error text-xs">{error}</span>
|
|
179
261
|
{/if}
|
|
180
262
|
</div>
|
|
263
|
+
{/if}
|
|
@@ -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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FieldDefinition } from '../../../types/crud.js';
|
|
2
|
+
export type FieldRow<T extends object = Record<string, unknown>> = FieldDefinition<T>[];
|
|
2
3
|
export type FieldGroup<T extends object = Record<string, unknown>> = {
|
|
3
4
|
title?: string;
|
|
4
|
-
|
|
5
|
+
rows: FieldRow<T>[];
|
|
5
6
|
};
|
|
6
7
|
export declare function groupFields<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[]): FieldGroup<T>[];
|
|
@@ -1,23 +1,45 @@
|
|
|
1
1
|
// Partitions a flat field list into visual groups for create/update/read
|
|
2
|
-
// views, based on `groupedAs
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// views, based on `groupedAs`, and each group's fields into rows, based on
|
|
3
|
+
// `row`. A field without `groupedAs` renders as its own single-field group
|
|
4
|
+
// (unchanged from today's flat layout) unless it shares a `row` with another
|
|
5
|
+
// ungrouped field, in which case they share that anonymous group. Fields
|
|
6
|
+
// sharing a `groupedAs` are collected together at the position of their first
|
|
7
|
+
// occurrence, preserving overall order; within that group, fields sharing a
|
|
8
|
+
// `row` are collected into the same row. A `row` never merges fields across
|
|
9
|
+
// two different `groupedAs` groups.
|
|
6
10
|
export function groupFields(fields) {
|
|
7
11
|
const groups = [];
|
|
8
12
|
const byTitle = new Map();
|
|
13
|
+
const byUngroupedRow = new Map();
|
|
9
14
|
for (const field of fields) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
let group;
|
|
16
|
+
if (field.groupedAs) {
|
|
17
|
+
group = byTitle.get(field.groupedAs);
|
|
18
|
+
if (!group) {
|
|
19
|
+
group = { title: field.groupedAs, rows: [] };
|
|
20
|
+
byTitle.set(field.groupedAs, group);
|
|
21
|
+
groups.push(group);
|
|
22
|
+
}
|
|
13
23
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
group
|
|
17
|
-
|
|
24
|
+
else if (field.row) {
|
|
25
|
+
group = byUngroupedRow.get(field.row);
|
|
26
|
+
if (!group) {
|
|
27
|
+
group = { rows: [] };
|
|
28
|
+
byUngroupedRow.set(field.row, group);
|
|
29
|
+
groups.push(group);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
group = { rows: [] };
|
|
18
34
|
groups.push(group);
|
|
19
35
|
}
|
|
20
|
-
group.
|
|
36
|
+
const existingRow = field.row ? group.rows.find((r) => r[0]?.row === field.row) : undefined;
|
|
37
|
+
if (existingRow) {
|
|
38
|
+
existingRow.push(field);
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
group.rows.push([field]);
|
|
42
|
+
}
|
|
21
43
|
}
|
|
22
44
|
return groups;
|
|
23
45
|
}
|
|
@@ -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,
|
|
@@ -55,6 +56,8 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
|
|
|
55
56
|
fields: m.fields
|
|
56
57
|
? buildFieldDefinitions(m.fields, data, excludedFlag, new Set())
|
|
57
58
|
: undefined,
|
|
58
|
-
itemLabel: m.itemLabel
|
|
59
|
+
itemLabel: m.itemLabel,
|
|
60
|
+
defaultExpanded: m.defaultExpanded,
|
|
61
|
+
row: m.row
|
|
59
62
|
}));
|
|
60
63
|
}
|
|
@@ -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) ?? '[]'));
|
|
@@ -113,14 +113,30 @@
|
|
|
113
113
|
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
114
114
|
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
115
115
|
<div class="flex flex-col gap-4">
|
|
116
|
-
{#each group.
|
|
117
|
-
|
|
116
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
117
|
+
{#if row.length > 1}
|
|
118
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
119
|
+
{#each row as field (field.attribute)}
|
|
120
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
121
|
+
{/each}
|
|
122
|
+
</div>
|
|
123
|
+
{:else}
|
|
124
|
+
<Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
|
|
125
|
+
{/if}
|
|
118
126
|
{/each}
|
|
119
127
|
</div>
|
|
120
128
|
</fieldset>
|
|
121
129
|
{:else}
|
|
122
|
-
{#each group.
|
|
123
|
-
|
|
130
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
131
|
+
{#if row.length > 1}
|
|
132
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
133
|
+
{#each row as field (field.attribute)}
|
|
134
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
135
|
+
{/each}
|
|
136
|
+
</div>
|
|
137
|
+
{:else}
|
|
138
|
+
<Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
|
|
139
|
+
{/if}
|
|
124
140
|
{/each}
|
|
125
141
|
{/if}
|
|
126
142
|
{/each}
|
|
@@ -81,14 +81,30 @@
|
|
|
81
81
|
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
82
82
|
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
83
83
|
<div class="flex flex-col gap-4">
|
|
84
|
-
{#each group.
|
|
85
|
-
|
|
84
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
85
|
+
{#if row.length > 1}
|
|
86
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
87
|
+
{#each row as field (field.attribute)}
|
|
88
|
+
<Field {field} {record} readonly class="md:min-w-0 md:flex-1" />
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
{:else}
|
|
92
|
+
<Field field={row[0]} {record} readonly />
|
|
93
|
+
{/if}
|
|
86
94
|
{/each}
|
|
87
95
|
</div>
|
|
88
96
|
</fieldset>
|
|
89
97
|
{:else}
|
|
90
|
-
{#each group.
|
|
91
|
-
|
|
98
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
99
|
+
{#if row.length > 1}
|
|
100
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
101
|
+
{#each row as field (field.attribute)}
|
|
102
|
+
<Field {field} {record} readonly class="md:min-w-0 md:flex-1" />
|
|
103
|
+
{/each}
|
|
104
|
+
</div>
|
|
105
|
+
{:else}
|
|
106
|
+
<Field field={row[0]} {record} readonly />
|
|
107
|
+
{/if}
|
|
92
108
|
{/each}
|
|
93
109
|
{/if}
|
|
94
110
|
{/each}
|
|
@@ -129,14 +129,30 @@
|
|
|
129
129
|
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
130
130
|
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
131
131
|
<div class="flex flex-col gap-4">
|
|
132
|
-
{#each group.
|
|
133
|
-
|
|
132
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
133
|
+
{#if row.length > 1}
|
|
134
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
135
|
+
{#each row as field (field.attribute)}
|
|
136
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
137
|
+
{/each}
|
|
138
|
+
</div>
|
|
139
|
+
{:else}
|
|
140
|
+
<Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
|
|
141
|
+
{/if}
|
|
134
142
|
{/each}
|
|
135
143
|
</div>
|
|
136
144
|
</fieldset>
|
|
137
145
|
{:else}
|
|
138
|
-
{#each group.
|
|
139
|
-
|
|
146
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
147
|
+
{#if row.length > 1}
|
|
148
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
149
|
+
{#each row as field (field.attribute)}
|
|
150
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
151
|
+
{/each}
|
|
152
|
+
</div>
|
|
153
|
+
{:else}
|
|
154
|
+
<Field field={row[0]} bind:record error={fieldErrors[row[0].attribute] ?? ''} />
|
|
155
|
+
{/if}
|
|
140
156
|
{/each}
|
|
141
157
|
{/if}
|
|
142
158
|
{/each}
|
|
@@ -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); position-try-fallbacks:flip-block;"
|
|
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;
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
popover="auto"
|
|
116
116
|
id={popId}
|
|
117
117
|
bind:this={popoverEl}
|
|
118
|
-
style="position-anchor:{anchorName}; width:anchor-size(width);"
|
|
118
|
+
style="position-anchor:{anchorName}; width:anchor-size(width); position-try-fallbacks:flip-block;"
|
|
119
119
|
class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
|
|
120
120
|
ontoggle={onToggle}
|
|
121
121
|
>
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
defaultExpanded = true,
|
|
13
|
+
}: {
|
|
14
|
+
name?: string;
|
|
15
|
+
value?: string[];
|
|
16
|
+
options?: SelectOption[];
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
columns?: number;
|
|
19
|
+
defaultExpanded?: boolean;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
const childrenByParent = $derived(buildChildrenByParent(options));
|
|
23
|
+
const selected = $derived(new Set(value));
|
|
24
|
+
const roots = $derived(childrenByParent.get(null) ?? []);
|
|
25
|
+
|
|
26
|
+
function toggle(nodeValue: string, checked: boolean) {
|
|
27
|
+
const affected = [nodeValue, ...collectDescendantIds(nodeValue, childrenByParent)];
|
|
28
|
+
if (checked) {
|
|
29
|
+
value = [...new Set([...value, ...affected])];
|
|
30
|
+
} else {
|
|
31
|
+
const affectedSet = new Set(affected);
|
|
32
|
+
value = value.filter((v) => !affectedSet.has(v));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<div
|
|
38
|
+
class="overflow-y-auto rounded-box border border-base-300 p-2"
|
|
39
|
+
style="max-height: var(--runeforge-tree-max-height, 24rem)"
|
|
40
|
+
class:opacity-50={disabled}
|
|
41
|
+
class:pointer-events-none={disabled}
|
|
42
|
+
>
|
|
43
|
+
{#if name}
|
|
44
|
+
<input type="hidden" {name} value={JSON.stringify(value)} />
|
|
45
|
+
{/if}
|
|
46
|
+
{#if roots.length === 0}
|
|
47
|
+
<p class="px-1 py-2 text-sm text-base-content/40">—</p>
|
|
48
|
+
{/if}
|
|
49
|
+
<div class:leaf-columns={columns > 1} style={`--columns: ${columns}`}>
|
|
50
|
+
{#each roots as root (root.value)}
|
|
51
|
+
<TreeNode node={root} {childrenByParent} {selected} onToggle={toggle} {disabled} depth={0} {columns} {defaultExpanded} />
|
|
52
|
+
{/each}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
@media (min-width: 1024px) {
|
|
58
|
+
.leaf-columns {
|
|
59
|
+
columns: var(--columns);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
defaultExpanded?: boolean;
|
|
9
|
+
};
|
|
10
|
+
declare const Tree: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
11
|
+
type Tree = ReturnType<typeof Tree>;
|
|
12
|
+
export default Tree;
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
defaultExpanded,
|
|
14
|
+
}: {
|
|
15
|
+
node: SelectOption;
|
|
16
|
+
childrenByParent: Map<string | null, SelectOption[]>;
|
|
17
|
+
selected: Set<string>;
|
|
18
|
+
onToggle: (value: string, checked: boolean) => void;
|
|
19
|
+
disabled: boolean;
|
|
20
|
+
depth: number;
|
|
21
|
+
columns: number;
|
|
22
|
+
defaultExpanded: boolean;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
let expanded = $state(defaultExpanded);
|
|
26
|
+
|
|
27
|
+
const children = $derived(childrenByParent.get(node.value) ?? []);
|
|
28
|
+
const hasChildren = $derived(children.length > 0);
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<div class="flex flex-col" style={`--columns: ${columns}`}>
|
|
32
|
+
<div
|
|
33
|
+
class="flex w-full items-center gap-1 rounded py-1 pr-2 transition-colors hover:bg-base-200"
|
|
34
|
+
style={`padding-left: ${depth * 1.25}rem`}
|
|
35
|
+
>
|
|
36
|
+
{#if hasChildren}
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
class="btn btn-ghost btn-xs btn-circle"
|
|
40
|
+
aria-label={expanded ? 'Collapse' : 'Expand'}
|
|
41
|
+
onclick={() => (expanded = !expanded)}
|
|
42
|
+
>
|
|
43
|
+
<span class="inline-block text-xs transition-transform" class:rotate-90={expanded}>▸</span>
|
|
44
|
+
</button>
|
|
45
|
+
{:else}
|
|
46
|
+
<span class="inline-block size-6"></span>
|
|
47
|
+
{/if}
|
|
48
|
+
|
|
49
|
+
<input
|
|
50
|
+
type="checkbox"
|
|
51
|
+
class="checkbox checkbox-sm"
|
|
52
|
+
checked={selected.has(node.value)}
|
|
53
|
+
{disabled}
|
|
54
|
+
onchange={(e) => onToggle(node.value, (e.currentTarget as HTMLInputElement).checked)}
|
|
55
|
+
aria-label={node.label}
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<span>{node.label}</span>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{#if expanded && hasChildren}
|
|
62
|
+
{#each children as child (child.value)}
|
|
63
|
+
<TreeNode node={child} {childrenByParent} {selected} {onToggle} {disabled} depth={depth + 1} {columns} {defaultExpanded} />
|
|
64
|
+
{/each}
|
|
65
|
+
{/if}
|
|
66
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
defaultExpanded: boolean;
|
|
12
|
+
};
|
|
13
|
+
declare const TreeNode: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type TreeNode = ReturnType<typeof TreeNode>;
|
|
15
|
+
export default TreeNode;
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
<div
|
|
93
93
|
popover="auto"
|
|
94
94
|
id={popId}
|
|
95
|
-
style="position-anchor:{anchor}"
|
|
95
|
+
style="position-anchor:{anchor}; position-try-fallbacks:flip-block;"
|
|
96
96
|
class="dropdown dropdown-end rounded-box border border-base-content/10 bg-base-100 p-3 shadow-lg"
|
|
97
97
|
class:w-56={!isDatetime}
|
|
98
98
|
>
|
package/dist/i18n/en.js
CHANGED
package/dist/i18n/es.js
CHANGED
package/dist/i18n/types.d.ts
CHANGED
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;
|
|
@@ -67,4 +78,10 @@ export type AttributeMetadata = {
|
|
|
67
78
|
fields?: InterfaceMetadata<any>;
|
|
68
79
|
/** Embedded fields only: short label for an item in the list. */
|
|
69
80
|
itemLabel?: EmbeddedItemLabelResolver;
|
|
81
|
+
/** Tree fields only: whether parent nodes start expanded. Defaults to `true`. */
|
|
82
|
+
defaultExpanded?: boolean;
|
|
83
|
+
/** Fields sharing the same `row` string render side by side on desktop and
|
|
84
|
+
* stacked on mobile — see the Field rows section. Only merges fields that
|
|
85
|
+
* are also in the same `groupedAs` bucket (or both ungrouped). */
|
|
86
|
+
row?: string;
|
|
70
87
|
};
|
package/dist/types/attribute.js
CHANGED
package/dist/types/crud.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
2
|
import type { FullAutoFill } from 'svelte/elements';
|
|
3
|
-
import type { AttributeType, SearchResolver, RequiredResolver } 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]: {
|
|
@@ -28,16 +28,11 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
|
|
|
28
28
|
autocomplete?: FullAutoFill;
|
|
29
29
|
placeholder?: string;
|
|
30
30
|
default?: any;
|
|
31
|
-
options?:
|
|
32
|
-
|
|
33
|
-
label: string;
|
|
34
|
-
}[];
|
|
35
|
-
dependentOptions?: (record: Record<string, unknown>) => {
|
|
36
|
-
value: string;
|
|
37
|
-
label: string;
|
|
38
|
-
}[];
|
|
31
|
+
options?: SelectOption[];
|
|
32
|
+
dependentOptions?: (record: Record<string, unknown>) => SelectOption[];
|
|
39
33
|
search?: SearchResolver;
|
|
40
34
|
disabled?: (record: Record<string, unknown>) => boolean;
|
|
35
|
+
hidden?: boolean | ((record: Record<string, unknown>) => boolean);
|
|
41
36
|
seed?: (instance: any) => unknown;
|
|
42
37
|
groupedAs?: string;
|
|
43
38
|
min?: number;
|
|
@@ -51,6 +46,10 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
|
|
|
51
46
|
fields?: FieldDefinition<Record<string, unknown>>[];
|
|
52
47
|
/** Embedded fields only: short label for an item in the list. */
|
|
53
48
|
itemLabel?: (item: Record<string, unknown>) => string;
|
|
49
|
+
/** Tree fields only: whether parent nodes start expanded. Defaults to `true`. */
|
|
50
|
+
defaultExpanded?: boolean;
|
|
51
|
+
/** Fields sharing the same `row` string render side by side. */
|
|
52
|
+
row?: string;
|
|
54
53
|
}
|
|
55
54
|
export interface ActionConfiguration<T extends object = Record<string, unknown>> {
|
|
56
55
|
enabled?: boolean;
|