runeforge 0.0.61 → 0.0.63

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.
Files changed (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1410 -1407
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +73 -73
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +203 -203
  8. package/dist/components/crud/Field.svelte +122 -122
  9. package/dist/components/crud/GenericCRUD.svelte +426 -426
  10. package/dist/components/crud/SearchInput.svelte +53 -53
  11. package/dist/components/crud/columns/Avatar.svelte +15 -15
  12. package/dist/components/crud/columns/Icon.svelte +8 -8
  13. package/dist/components/crud/fields/BooleanField.svelte +38 -38
  14. package/dist/components/crud/fields/DatetimeField.svelte +250 -250
  15. package/dist/components/crud/fields/DefaultField.svelte +67 -67
  16. package/dist/components/crud/fields/FileField.svelte +70 -70
  17. package/dist/components/crud/fields/MultiSelectField.svelte +70 -70
  18. package/dist/components/crud/fields/SelectField.svelte +67 -67
  19. package/dist/components/crud/fields/TextareaField.svelte +55 -55
  20. package/dist/components/crud/fields/TreeField.svelte +1 -0
  21. package/dist/components/crud/utils/resolution.js +1 -0
  22. package/dist/components/crud/utils/tree.d.ts +5 -0
  23. package/dist/components/crud/utils/tree.js +29 -0
  24. package/dist/components/crud/views/Create.svelte +239 -239
  25. package/dist/components/crud/views/Read.svelte +131 -131
  26. package/dist/components/crud/views/Update.svelte +215 -215
  27. package/dist/components/crud/views/list/List.svelte +291 -291
  28. package/dist/components/crud/views/list/Modals.svelte +56 -56
  29. package/dist/components/crud/views/list/Table.svelte +173 -173
  30. package/dist/components/crud/views/list/Toolbar.svelte +340 -340
  31. package/dist/components/form/Button.svelte +27 -27
  32. package/dist/components/form/Label.svelte +37 -37
  33. package/dist/components/form/MultiSelect.svelte +248 -248
  34. package/dist/components/form/PasswordInput.svelte +68 -68
  35. package/dist/components/form/Required.svelte +1 -1
  36. package/dist/components/form/Select.svelte +209 -209
  37. package/dist/components/form/Tree.svelte +43 -4
  38. package/dist/components/form/Tree.svelte.d.ts +1 -0
  39. package/dist/components/form/TreeNode.svelte +31 -2
  40. package/dist/components/form/TreeNode.svelte.d.ts +2 -0
  41. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  42. package/dist/components/table/ColumnFilter.svelte +168 -168
  43. package/dist/components/table/PaginatedTable.svelte +542 -542
  44. package/dist/components/table/Paginator.svelte +113 -113
  45. package/dist/components/table/SortHeader.svelte +43 -43
  46. package/dist/components/table/TableBody.svelte +150 -150
  47. package/dist/components/table/TableHeader.svelte +88 -88
  48. package/dist/components/table/state.svelte.js +7 -2
  49. package/dist/icons/defaults/Clear.svelte +6 -6
  50. package/dist/icons/defaults/Create.svelte +6 -6
  51. package/dist/icons/defaults/Delete.svelte +6 -6
  52. package/dist/icons/defaults/Download.svelte +7 -7
  53. package/dist/icons/defaults/Edit.svelte +7 -7
  54. package/dist/icons/defaults/Filter.svelte +6 -6
  55. package/dist/icons/defaults/FilterActive.svelte +6 -6
  56. package/dist/icons/defaults/Folder.svelte +6 -6
  57. package/dist/icons/defaults/Grip.svelte +7 -7
  58. package/dist/icons/defaults/Home.svelte +6 -6
  59. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  60. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  61. package/dist/icons/defaults/SortAsc.svelte +6 -6
  62. package/dist/icons/defaults/SortDesc.svelte +6 -6
  63. package/dist/icons/defaults/SortNone.svelte +6 -6
  64. package/dist/icons/defaults/View.svelte +7 -7
  65. package/dist/types/attribute.d.ts +3 -0
  66. package/dist/types/crud/fields.d.ts +3 -0
  67. package/package.json +1 -1
@@ -1,70 +1,70 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import type { Snippet } from 'svelte';
3
- import Avatar from '../../Avatar.svelte';
4
- import { fieldLabel, initials } from '../utils/misc.js';
5
- import type { FieldDefinition } from '../../../types/crud/fields.js';
6
-
7
- let {
8
- field,
9
- record = $bindable({} as Record<string, unknown>),
10
- parentRecord = undefined,
11
- error = '',
12
- readonly = false,
13
- labelRow,
14
- errorRow
15
- }: {
16
- field: FieldDefinition<T>;
17
- record?: Record<string, unknown>;
18
- parentRecord?: Record<string, unknown>;
19
- error?: string;
20
- readonly?: boolean;
21
- labelRow: Snippet;
22
- errorRow: Snippet;
23
- } = $props();
24
-
25
- const name = $derived(readonly ? undefined : field.attribute);
26
- const saved = $derived(record[field.attribute]);
27
- const labelText = $derived(fieldLabel(field));
28
- const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
29
- const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
30
-
31
- let filePreview = $state<string | null>(null);
32
- const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
33
-
34
- function onFileChange(e: Event & { currentTarget: HTMLInputElement }) {
35
- const file = e.currentTarget.files?.[0];
36
- if (filePreview) URL.revokeObjectURL(filePreview);
37
- filePreview = file && file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
38
- }
39
-
40
- $effect(() => () => {
41
- if (filePreview) URL.revokeObjectURL(filePreview);
42
- });
43
- </script>
44
-
45
- <div class="flex justify-center">
46
- <Avatar
47
- src={preview}
48
- text={avatarInitials}
49
- alt={labelText}
50
- class="w-20 rounded-full"
51
- textClass="text-xl"
52
- />
53
- </div>
54
-
55
- {@render labelRow()}
56
-
57
- {#if !readonly}
58
- <input
59
- type="file"
60
- id={field.attribute}
61
- {name}
62
- class="file-input file-input-bordered w-full"
63
- class:file-input-error={!!error}
64
- disabled={fieldDisabled}
65
- onchange={onFileChange}
66
- />
67
- {/if}
68
- <!-- read-only file value is shown as the avatar above -->
69
-
70
- {@render errorRow()}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import type { Snippet } from 'svelte';
3
+ import Avatar from '../../Avatar.svelte';
4
+ import { fieldLabel, initials } from '../utils/misc.js';
5
+ import type { FieldDefinition } from '../../../types/crud/fields.js';
6
+
7
+ let {
8
+ field,
9
+ record = $bindable({} as Record<string, unknown>),
10
+ parentRecord = undefined,
11
+ error = '',
12
+ readonly = false,
13
+ labelRow,
14
+ errorRow
15
+ }: {
16
+ field: FieldDefinition<T>;
17
+ record?: Record<string, unknown>;
18
+ parentRecord?: Record<string, unknown>;
19
+ error?: string;
20
+ readonly?: boolean;
21
+ labelRow: Snippet;
22
+ errorRow: Snippet;
23
+ } = $props();
24
+
25
+ const name = $derived(readonly ? undefined : field.attribute);
26
+ const saved = $derived(record[field.attribute]);
27
+ const labelText = $derived(fieldLabel(field));
28
+ const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
29
+ const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
30
+
31
+ let filePreview = $state<string | null>(null);
32
+ const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
33
+
34
+ function onFileChange(e: Event & { currentTarget: HTMLInputElement }) {
35
+ const file = e.currentTarget.files?.[0];
36
+ if (filePreview) URL.revokeObjectURL(filePreview);
37
+ filePreview = file && file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
38
+ }
39
+
40
+ $effect(() => () => {
41
+ if (filePreview) URL.revokeObjectURL(filePreview);
42
+ });
43
+ </script>
44
+
45
+ <div class="flex justify-center">
46
+ <Avatar
47
+ src={preview}
48
+ text={avatarInitials}
49
+ alt={labelText}
50
+ class="w-20 rounded-full"
51
+ textClass="text-xl"
52
+ />
53
+ </div>
54
+
55
+ {@render labelRow()}
56
+
57
+ {#if !readonly}
58
+ <input
59
+ type="file"
60
+ id={field.attribute}
61
+ {name}
62
+ class="file-input file-input-bordered w-full"
63
+ class:file-input-error={!!error}
64
+ disabled={fieldDisabled}
65
+ onchange={onFileChange}
66
+ />
67
+ {/if}
68
+ <!-- read-only file value is shown as the avatar above -->
69
+
70
+ {@render errorRow()}
@@ -1,70 +1,70 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import type { Snippet } from 'svelte';
3
- import MultiSelect from '../../form/MultiSelect.svelte';
4
- import type { FieldDefinition } from '../../../types/crud/fields.js';
5
-
6
- let {
7
- field,
8
- record = $bindable({} as Record<string, unknown>),
9
- parentRecord = undefined,
10
- error = '',
11
- readonly = false,
12
- labelRow,
13
- errorRow
14
- }: {
15
- field: FieldDefinition<T>;
16
- record?: Record<string, unknown>;
17
- parentRecord?: Record<string, unknown>;
18
- error?: string;
19
- readonly?: boolean;
20
- labelRow: Snippet;
21
- errorRow: Snippet;
22
- } = $props();
23
-
24
- const saved = $derived(record[field.attribute]);
25
- const selectOptions = $derived(
26
- field.dependentOptions ? field.dependentOptions(record, parentRecord) : (field.options ?? [])
27
- );
28
- const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
29
-
30
- // See SelectField's equivalent effect — same idea, but pruning the array
31
- // down to values still present in the (possibly just-changed) options
32
- // instead of clearing a single scalar value outright.
33
- $effect(() => {
34
- if (!field.dependentOptions) return;
35
- const current = record[field.attribute];
36
- if (!Array.isArray(current)) return;
37
- const validValues = new Set(selectOptions.map((o) => o.value));
38
- const pruned = current.filter((v) => validValues.has(String(v)));
39
- if (pruned.length !== current.length) {
40
- record[field.attribute] = pruned;
41
- }
42
- });
43
- </script>
44
-
45
- {@render labelRow()}
46
-
47
- {#if readonly}
48
- <input
49
- type="text"
50
- id={field.attribute}
51
- class="input input-bordered w-full"
52
- value={(Array.isArray(saved) ? saved : [])
53
- .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
54
- .join(', ')}
55
- disabled
56
- />
57
- {:else}
58
- <MultiSelect
59
- id={field.attribute}
60
- name={field.attribute}
61
- bind:value={record[field.attribute] as string[]}
62
- options={selectOptions}
63
- search={field.search}
64
- placeholder={field.placeholder}
65
- disabled={fieldDisabled}
66
- {error}
67
- />
68
- {/if}
69
-
70
- {@render errorRow()}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import type { Snippet } from 'svelte';
3
+ import MultiSelect from '../../form/MultiSelect.svelte';
4
+ import type { FieldDefinition } from '../../../types/crud/fields.js';
5
+
6
+ let {
7
+ field,
8
+ record = $bindable({} as Record<string, unknown>),
9
+ parentRecord = undefined,
10
+ error = '',
11
+ readonly = false,
12
+ labelRow,
13
+ errorRow
14
+ }: {
15
+ field: FieldDefinition<T>;
16
+ record?: Record<string, unknown>;
17
+ parentRecord?: Record<string, unknown>;
18
+ error?: string;
19
+ readonly?: boolean;
20
+ labelRow: Snippet;
21
+ errorRow: Snippet;
22
+ } = $props();
23
+
24
+ const saved = $derived(record[field.attribute]);
25
+ const selectOptions = $derived(
26
+ field.dependentOptions ? field.dependentOptions(record, parentRecord) : (field.options ?? [])
27
+ );
28
+ const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
29
+
30
+ // See SelectField's equivalent effect — same idea, but pruning the array
31
+ // down to values still present in the (possibly just-changed) options
32
+ // instead of clearing a single scalar value outright.
33
+ $effect(() => {
34
+ if (!field.dependentOptions) return;
35
+ const current = record[field.attribute];
36
+ if (!Array.isArray(current)) return;
37
+ const validValues = new Set(selectOptions.map((o) => o.value));
38
+ const pruned = current.filter((v) => validValues.has(String(v)));
39
+ if (pruned.length !== current.length) {
40
+ record[field.attribute] = pruned;
41
+ }
42
+ });
43
+ </script>
44
+
45
+ {@render labelRow()}
46
+
47
+ {#if readonly}
48
+ <input
49
+ type="text"
50
+ id={field.attribute}
51
+ class="input input-bordered w-full"
52
+ value={(Array.isArray(saved) ? saved : [])
53
+ .map((v) => selectOptions.find((o) => o.value === String(v))?.label ?? String(v))
54
+ .join(', ')}
55
+ disabled
56
+ />
57
+ {:else}
58
+ <MultiSelect
59
+ id={field.attribute}
60
+ name={field.attribute}
61
+ bind:value={record[field.attribute] as string[]}
62
+ options={selectOptions}
63
+ search={field.search}
64
+ placeholder={field.placeholder}
65
+ disabled={fieldDisabled}
66
+ {error}
67
+ />
68
+ {/if}
69
+
70
+ {@render errorRow()}
@@ -1,67 +1,67 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import type { Snippet } from 'svelte';
3
- import Select from '../../form/Select.svelte';
4
- import type { FieldDefinition } from '../../../types/crud/fields.js';
5
-
6
- let {
7
- field,
8
- record = $bindable({} as Record<string, unknown>),
9
- parentRecord = undefined,
10
- error = '',
11
- readonly = false,
12
- labelRow,
13
- errorRow
14
- }: {
15
- field: FieldDefinition<T>;
16
- record?: Record<string, unknown>;
17
- parentRecord?: Record<string, unknown>;
18
- error?: string;
19
- readonly?: boolean;
20
- labelRow: Snippet;
21
- errorRow: Snippet;
22
- } = $props();
23
-
24
- const saved = $derived(record[field.attribute]);
25
- const displayValue = $derived(saved == null ? '' : String(saved));
26
- const selectOptions = $derived(
27
- field.dependentOptions ? field.dependentOptions(record, parentRecord) : (field.options ?? [])
28
- );
29
- const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
30
-
31
- // A previously-valid selection can fall outside a dependentOptions list
32
- // that just changed (e.g. its options depend on a sibling/parent field) —
33
- // clears it instead of silently keeping a value the user can no longer see
34
- // or re-pick from the dropdown.
35
- $effect(() => {
36
- if (!field.dependentOptions) return;
37
- const current = record[field.attribute];
38
- if (current && !selectOptions.some((o) => o.value === String(current))) {
39
- record[field.attribute] = '';
40
- }
41
- });
42
- </script>
43
-
44
- {@render labelRow()}
45
-
46
- {#if readonly}
47
- <input
48
- type="text"
49
- id={field.attribute}
50
- class="input input-bordered w-full"
51
- value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
52
- disabled
53
- />
54
- {:else}
55
- <Select
56
- id={field.attribute}
57
- name={field.attribute}
58
- bind:value={record[field.attribute] as string}
59
- options={selectOptions}
60
- search={field.search}
61
- placeholder={field.placeholder}
62
- disabled={fieldDisabled}
63
- {error}
64
- />
65
- {/if}
66
-
67
- {@render errorRow()}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import type { Snippet } from 'svelte';
3
+ import Select from '../../form/Select.svelte';
4
+ import type { FieldDefinition } from '../../../types/crud/fields.js';
5
+
6
+ let {
7
+ field,
8
+ record = $bindable({} as Record<string, unknown>),
9
+ parentRecord = undefined,
10
+ error = '',
11
+ readonly = false,
12
+ labelRow,
13
+ errorRow
14
+ }: {
15
+ field: FieldDefinition<T>;
16
+ record?: Record<string, unknown>;
17
+ parentRecord?: Record<string, unknown>;
18
+ error?: string;
19
+ readonly?: boolean;
20
+ labelRow: Snippet;
21
+ errorRow: Snippet;
22
+ } = $props();
23
+
24
+ const saved = $derived(record[field.attribute]);
25
+ const displayValue = $derived(saved == null ? '' : String(saved));
26
+ const selectOptions = $derived(
27
+ field.dependentOptions ? field.dependentOptions(record, parentRecord) : (field.options ?? [])
28
+ );
29
+ const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
30
+
31
+ // A previously-valid selection can fall outside a dependentOptions list
32
+ // that just changed (e.g. its options depend on a sibling/parent field) —
33
+ // clears it instead of silently keeping a value the user can no longer see
34
+ // or re-pick from the dropdown.
35
+ $effect(() => {
36
+ if (!field.dependentOptions) return;
37
+ const current = record[field.attribute];
38
+ if (current && !selectOptions.some((o) => o.value === String(current))) {
39
+ record[field.attribute] = '';
40
+ }
41
+ });
42
+ </script>
43
+
44
+ {@render labelRow()}
45
+
46
+ {#if readonly}
47
+ <input
48
+ type="text"
49
+ id={field.attribute}
50
+ class="input input-bordered w-full"
51
+ value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
52
+ disabled
53
+ />
54
+ {:else}
55
+ <Select
56
+ id={field.attribute}
57
+ name={field.attribute}
58
+ bind:value={record[field.attribute] as string}
59
+ options={selectOptions}
60
+ search={field.search}
61
+ placeholder={field.placeholder}
62
+ disabled={fieldDisabled}
63
+ {error}
64
+ />
65
+ {/if}
66
+
67
+ {@render errorRow()}
@@ -1,55 +1,55 @@
1
- <script lang="ts" generics="T extends object = Record<string, unknown>">
2
- import type { Snippet } from 'svelte';
3
- import type { FieldDefinition } from '../../../types/crud/fields.js';
4
-
5
- let {
6
- field,
7
- record = $bindable({} as Record<string, unknown>),
8
- parentRecord = undefined,
9
- error = '',
10
- readonly = false,
11
- labelRow,
12
- errorRow
13
- }: {
14
- field: FieldDefinition<T>;
15
- record?: Record<string, unknown>;
16
- parentRecord?: Record<string, unknown>;
17
- error?: string;
18
- readonly?: boolean;
19
- labelRow: Snippet;
20
- errorRow: Snippet;
21
- } = $props();
22
-
23
- const name = $derived(readonly ? undefined : field.attribute);
24
- const saved = $derived(record[field.attribute]);
25
- const displayValue = $derived(saved == null ? '' : String(saved));
26
- const formattedValue = $derived(
27
- readonly && field.formatter ? field.formatter(saved, record) : displayValue
28
- );
29
- const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
30
- </script>
31
-
32
- {@render labelRow()}
33
-
34
- {#if readonly}
35
- <textarea
36
- id={field.attribute}
37
- rows={field.rows}
38
- class="textarea textarea-bordered bg-base-100 w-full"
39
- value={formattedValue}
40
- disabled
41
- ></textarea>
42
- {:else}
43
- <textarea
44
- id={field.attribute}
45
- {name}
46
- rows={field.rows}
47
- placeholder={field.placeholder ?? ''}
48
- bind:value={record[field.attribute]}
49
- class="textarea textarea-bordered bg-base-100 w-full"
50
- class:textarea-error={!!error}
51
- disabled={fieldDisabled}
52
- ></textarea>
53
- {/if}
54
-
55
- {@render errorRow()}
1
+ <script lang="ts" generics="T extends object = Record<string, unknown>">
2
+ import type { Snippet } from 'svelte';
3
+ import type { FieldDefinition } from '../../../types/crud/fields.js';
4
+
5
+ let {
6
+ field,
7
+ record = $bindable({} as Record<string, unknown>),
8
+ parentRecord = undefined,
9
+ error = '',
10
+ readonly = false,
11
+ labelRow,
12
+ errorRow
13
+ }: {
14
+ field: FieldDefinition<T>;
15
+ record?: Record<string, unknown>;
16
+ parentRecord?: Record<string, unknown>;
17
+ error?: string;
18
+ readonly?: boolean;
19
+ labelRow: Snippet;
20
+ errorRow: Snippet;
21
+ } = $props();
22
+
23
+ const name = $derived(readonly ? undefined : field.attribute);
24
+ const saved = $derived(record[field.attribute]);
25
+ const displayValue = $derived(saved == null ? '' : String(saved));
26
+ const formattedValue = $derived(
27
+ readonly && field.formatter ? field.formatter(saved, record) : displayValue
28
+ );
29
+ const fieldDisabled = $derived(field.disabled ? field.disabled(record, parentRecord) : false);
30
+ </script>
31
+
32
+ {@render labelRow()}
33
+
34
+ {#if readonly}
35
+ <textarea
36
+ id={field.attribute}
37
+ rows={field.rows}
38
+ class="textarea textarea-bordered bg-base-100 w-full"
39
+ value={formattedValue}
40
+ disabled
41
+ ></textarea>
42
+ {:else}
43
+ <textarea
44
+ id={field.attribute}
45
+ {name}
46
+ rows={field.rows}
47
+ placeholder={field.placeholder ?? ''}
48
+ bind:value={record[field.attribute]}
49
+ class="textarea textarea-bordered bg-base-100 w-full"
50
+ class:textarea-error={!!error}
51
+ disabled={fieldDisabled}
52
+ ></textarea>
53
+ {/if}
54
+
55
+ {@render errorRow()}
@@ -57,6 +57,7 @@
57
57
  options={selectOptions}
58
58
  disabled={fieldDisabled}
59
59
  defaultExpanded={field.defaultExpanded ?? true}
60
+ searchable={field.searchable ?? true}
60
61
  />
61
62
  {/if}
62
63
 
@@ -75,6 +75,7 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
75
75
  revalidate: m.revalidate,
76
76
  dependsOn: m.dependsOn,
77
77
  defaultExpanded: m.defaultExpanded,
78
+ searchable: m.searchable,
78
79
  row: m.row,
79
80
  actions: m.actions
80
81
  }));
@@ -1,3 +1,8 @@
1
1
  import type { SelectOption } from '../../../types/attribute.js';
2
2
  export declare function buildChildrenByParent(options: SelectOption[]): Map<string | null, SelectOption[]>;
3
3
  export declare function collectDescendantIds(value: string, childrenByParent: Map<string | null, SelectOption[]>): string[];
4
+ export interface TreeSearchState {
5
+ visible: Set<string>;
6
+ forceExpanded: Set<string>;
7
+ }
8
+ export declare function buildTreeSearchState(query: string, childrenByParent: Map<string | null, SelectOption[]>): TreeSearchState | null;
@@ -25,3 +25,32 @@ export function collectDescendantIds(value, childrenByParent) {
25
25
  visit(value);
26
26
  return ids;
27
27
  }
28
+ // Shared by Tree.svelte: narrows the tree to whatever matches the typed
29
+ // query, by label, case-insensitively. Returns null for a blank query so
30
+ // callers can tell "no filter" apart from "filter matched nothing".
31
+ export function buildTreeSearchState(query, childrenByParent) {
32
+ const needle = query.trim().toLowerCase();
33
+ if (!needle)
34
+ return null;
35
+ const visible = new Set();
36
+ const forceExpanded = new Set();
37
+ function visit(node) {
38
+ let hasMatchingDescendant = false;
39
+ for (const child of childrenByParent.get(node.value) ?? []) {
40
+ if (visit(child))
41
+ hasMatchingDescendant = true;
42
+ }
43
+ const selfMatches = node.label.toLowerCase().includes(needle);
44
+ if (selfMatches || hasMatchingDescendant) {
45
+ visible.add(node.value);
46
+ if (hasMatchingDescendant)
47
+ forceExpanded.add(node.value);
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ for (const root of childrenByParent.get(null) ?? []) {
53
+ visit(root);
54
+ }
55
+ return { visible, forceExpanded };
56
+ }