runeforge 0.0.53 → 0.0.55

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 (78) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1342 -1222
  3. package/dist/components/Avatar.svelte +31 -31
  4. package/dist/components/IconRenderer.svelte +22 -22
  5. package/dist/components/Modal.svelte +75 -75
  6. package/dist/components/common/Header.svelte +40 -40
  7. package/dist/components/crud/EmbeddedField.svelte +175 -175
  8. package/dist/components/crud/Field.svelte +376 -376
  9. package/dist/components/crud/GenericCRUD.svelte +426 -435
  10. package/dist/components/crud/GenericCRUD.svelte.d.ts +6 -9
  11. package/dist/components/crud/SearchInput.svelte +53 -53
  12. package/dist/components/crud/columns/Avatar.svelte +15 -15
  13. package/dist/components/crud/columns/Icon.svelte +8 -8
  14. package/dist/components/crud/utils/reorder.d.ts +4 -0
  15. package/dist/components/crud/utils/reorder.js +13 -0
  16. package/dist/components/crud/views/Create.svelte +232 -232
  17. package/dist/components/crud/views/Read.svelte +124 -124
  18. package/dist/components/crud/views/Update.svelte +208 -208
  19. package/dist/components/crud/views/list/List.svelte +291 -0
  20. package/dist/components/crud/views/{List.svelte.d.ts → list/List.svelte.d.ts} +4 -14
  21. package/dist/components/crud/views/list/Modals.svelte +56 -0
  22. package/dist/components/crud/views/list/Modals.svelte.d.ts +36 -0
  23. package/dist/components/crud/views/list/Table.svelte +176 -0
  24. package/dist/components/crud/views/list/Table.svelte.d.ts +59 -0
  25. package/dist/components/crud/views/list/Toolbar.svelte +341 -0
  26. package/dist/components/crud/views/list/Toolbar.svelte.d.ts +46 -0
  27. package/dist/components/form/Button.svelte +27 -27
  28. package/dist/components/form/Label.svelte +37 -37
  29. package/dist/components/form/MultiSelect.svelte +248 -248
  30. package/dist/components/form/PasswordInput.svelte +68 -68
  31. package/dist/components/form/Required.svelte +1 -1
  32. package/dist/components/form/Select.svelte +209 -209
  33. package/dist/components/form/Tree.svelte +62 -62
  34. package/dist/components/form/TreeNode.svelte +66 -66
  35. package/dist/components/navigation/Breadcrumbs.svelte +111 -111
  36. package/dist/components/table/ColumnFilter.svelte +168 -168
  37. package/dist/components/table/PaginatedTable.svelte +536 -215
  38. package/dist/components/table/PaginatedTable.svelte.d.ts +11 -2
  39. package/dist/components/table/Paginator.svelte +113 -113
  40. package/dist/components/table/SortHeader.svelte +43 -43
  41. package/dist/components/table/TableBody.svelte +150 -70
  42. package/dist/components/table/TableBody.svelte.d.ts +14 -1
  43. package/dist/components/table/TableHeader.svelte +88 -83
  44. package/dist/components/table/TableHeader.svelte.d.ts +1 -0
  45. package/dist/components/table/sortable.d.ts +27 -0
  46. package/dist/components/table/sortable.js +55 -0
  47. package/dist/components/table/utils.d.ts +28 -1
  48. package/dist/components/table/utils.js +75 -0
  49. package/dist/i18n/en.js +2 -0
  50. package/dist/i18n/es.js +2 -0
  51. package/dist/i18n/types.d.ts +2 -0
  52. package/dist/icons/defaults/Clear.svelte +6 -6
  53. package/dist/icons/defaults/Create.svelte +6 -6
  54. package/dist/icons/defaults/Delete.svelte +6 -6
  55. package/dist/icons/defaults/Download.svelte +7 -7
  56. package/dist/icons/defaults/Edit.svelte +7 -7
  57. package/dist/icons/defaults/Filter.svelte +6 -6
  58. package/dist/icons/defaults/FilterActive.svelte +6 -6
  59. package/dist/icons/defaults/Folder.svelte +6 -6
  60. package/dist/icons/defaults/Grip.svelte +7 -0
  61. package/dist/icons/defaults/Grip.svelte.d.ts +7 -0
  62. package/dist/icons/defaults/Home.svelte +6 -6
  63. package/dist/icons/defaults/PasswordHide.svelte +9 -9
  64. package/dist/icons/defaults/PasswordShow.svelte +7 -7
  65. package/dist/icons/defaults/SortAsc.svelte +6 -6
  66. package/dist/icons/defaults/SortDesc.svelte +6 -6
  67. package/dist/icons/defaults/SortNone.svelte +6 -6
  68. package/dist/icons/defaults/View.svelte +7 -7
  69. package/dist/icons/sets/bootstrap.js +2 -1
  70. package/dist/icons/sets/default.js +2 -0
  71. package/dist/icons/types.d.ts +3 -0
  72. package/dist/index.d.ts +5 -4
  73. package/dist/index.js +3 -2
  74. package/dist/types/attribute.d.ts +9 -0
  75. package/dist/types/crud.d.ts +72 -1
  76. package/dist/types/table.d.ts +56 -0
  77. package/package.json +3 -1
  78. package/dist/components/crud/views/List.svelte +0 -584
@@ -1,209 +1,209 @@
1
- <script lang="ts">
2
- import { getStrings } from '../../i18n/context.js';
3
- import type { SearchResolver } from '../../types/attribute.js';
4
-
5
- const strings = getStrings();
6
-
7
- let {
8
- name,
9
- id,
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
- id?: string;
20
- value?: string;
21
- options?: { value: string; label: string }[];
22
- search?: SearchResolver;
23
- searchDebounceMs?: number;
24
- placeholder?: string;
25
- error?: string;
26
- disabled?: boolean;
27
- } = $props();
28
-
29
- const popId = $props.id();
30
- const anchorName = `--select-anchor-${popId}`;
31
-
32
- let query = $state('');
33
- let open = $state(false);
34
- let containerEl: HTMLElement | undefined = $state();
35
- let popoverEl: HTMLElement | undefined = $state();
36
- let inputEl: HTMLInputElement | undefined = $state();
37
-
38
- // Options resolved by `searchFn` for the current query; null while no
39
- // server search has run yet (e.g. box just opened, query still empty).
40
- let remoteResults = $state<{ value: string; label: string }[] | null>(null);
41
- let searching = $state(false);
42
- // Label of whatever was last picked from `remoteResults`, kept around so the
43
- // closed-state input can still show it even though it isn't in `options`.
44
- let pickedLabel = $state<string | null>(null);
45
-
46
- let searchToken = 0;
47
- $effect(() => {
48
- if (!searchFn) return;
49
- const q = query.trim();
50
- if (!q) {
51
- remoteResults = null;
52
- searching = false;
53
- return;
54
- }
55
- const token = ++searchToken;
56
- searching = true;
57
- const timer = setTimeout(() => {
58
- searchFn(q).then((results) => {
59
- if (token !== searchToken) return; // stale response, a newer query took over
60
- remoteResults = results;
61
- searching = false;
62
- });
63
- }, searchDebounceMs);
64
- return () => clearTimeout(timer);
65
- });
66
-
67
- const filtered = $derived(
68
- searchFn
69
- ? (remoteResults ?? options)
70
- : query.trim()
71
- ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
72
- : options
73
- );
74
-
75
- const selectedLabel = $derived(
76
- value === '' ? '' : (options.find((o) => o.value === value)?.label ?? pickedLabel ?? '')
77
- );
78
-
79
- function openPopover() {
80
- open = true;
81
- query = '';
82
- popoverEl?.showPopover();
83
- }
84
-
85
- function pick(option: { value: string; label: string }) {
86
- value = option.value;
87
- pickedLabel = option.label;
88
- query = '';
89
- popoverEl?.hidePopover();
90
- inputEl?.blur();
91
- }
92
-
93
- function clear() {
94
- value = '';
95
- pickedLabel = null;
96
- query = '';
97
- popoverEl?.hidePopover();
98
- inputEl?.blur();
99
- }
100
-
101
- // Catches dismissal paths that don't go through pick()/clear() (outside
102
- // click, Escape), so a stale search doesn't linger for next time it opens.
103
- function onToggle(e: Event) {
104
- if ((e as ToggleEvent).newState === 'closed') {
105
- open = false;
106
- query = '';
107
- }
108
- }
109
-
110
- // Escape can be pressed while an option button has focus (not just the
111
- // text input), so this is wired at the container level rather than on
112
- // the input alone — otherwise picking an option and then hitting Escape
113
- // would leave the popover open with nothing listening for the key.
114
- function onKeydown(e: KeyboardEvent) {
115
- if (e.key === 'Enter' && e.target === inputEl) {
116
- // The trigger used to be a plain button, so Enter never submitted the
117
- // surrounding form. Now that it's a text input, guard against that.
118
- e.preventDefault();
119
- } else if (e.key === 'Escape') {
120
- // Also suppress the browser's native "revert to last value" behavior
121
- // for text inputs on Escape, which would otherwise race our own
122
- // close-and-restore-label update and blank the field.
123
- e.preventDefault();
124
- popoverEl?.hidePopover();
125
- }
126
- }
127
-
128
- // The trigger is a text input now, not a button, so it can't rely on the
129
- // native `popovertarget` invoker exclusion to survive its own opening
130
- // click (that relationship only applies to button-like elements — for a
131
- // text input, the click that focuses it and opens the popover would
132
- // immediately light-dismiss it again). `popover="manual"` opts out of
133
- // that dismiss behavior entirely; this handler reimplements "close when
134
- // focus leaves the widget" instead.
135
- function onFocusOut(e: FocusEvent) {
136
- const next = e.relatedTarget as Node | null;
137
- if (!next || !containerEl?.contains(next)) {
138
- popoverEl?.hidePopover();
139
- }
140
- }
141
- </script>
142
-
143
- <!--
144
- The keydown/focusout handlers here are event delegation for the input and
145
- popover option buttons below, not interaction with this div itself — every
146
- actually-interactive element inside already has correct native semantics.
147
- -->
148
- <!-- svelte-ignore a11y_no_static_element_interactions -->
149
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
150
- <div class="relative w-full" bind:this={containerEl} onfocusout={onFocusOut} onkeydown={onKeydown}>
151
- {#if name}
152
- <input type="hidden" {name} {value} />
153
- {/if}
154
-
155
- <input
156
- bind:this={inputEl}
157
- {id}
158
- type="text"
159
- class="input input-bordered w-full font-normal"
160
- class:input-error={!!error}
161
- {disabled}
162
- {placeholder}
163
- value={open ? query : selectedLabel}
164
- oninput={(e) => (query = e.currentTarget.value)}
165
- onfocus={openPopover}
166
- style="anchor-name:{anchorName}"
167
- autocomplete="off"
168
- />
169
-
170
- <div
171
- popover="manual"
172
- id={popId}
173
- bind:this={popoverEl}
174
- style="position-anchor:{anchorName}; width:anchor-size(width); position-try-fallbacks:flip-block;"
175
- class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
176
- ontoggle={onToggle}
177
- >
178
- <ul class="max-h-48 overflow-y-auto p-1">
179
- <li>
180
- <button
181
- type="button"
182
- class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
183
- onclick={clear}
184
- >
185
- {placeholder}
186
- </button>
187
- </li>
188
- {#if searching}
189
- <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
190
- {/if}
191
- {#each filtered as option (option.value)}
192
- <li>
193
- <button
194
- type="button"
195
- class="w-full rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
196
- class:bg-primary={value === option.value}
197
- class:text-primary-content={value === option.value}
198
- onclick={() => pick(option)}
199
- >
200
- {option.label}
201
- </button>
202
- </li>
203
- {/each}
204
- {#if !searching && filtered.length === 0}
205
- <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
206
- {/if}
207
- </ul>
208
- </div>
209
- </div>
1
+ <script lang="ts">
2
+ import { getStrings } from '../../i18n/context.js';
3
+ import type { SearchResolver } from '../../types/attribute.js';
4
+
5
+ const strings = getStrings();
6
+
7
+ let {
8
+ name,
9
+ id,
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
+ id?: string;
20
+ value?: string;
21
+ options?: { value: string; label: string }[];
22
+ search?: SearchResolver;
23
+ searchDebounceMs?: number;
24
+ placeholder?: string;
25
+ error?: string;
26
+ disabled?: boolean;
27
+ } = $props();
28
+
29
+ const popId = $props.id();
30
+ const anchorName = `--select-anchor-${popId}`;
31
+
32
+ let query = $state('');
33
+ let open = $state(false);
34
+ let containerEl: HTMLElement | undefined = $state();
35
+ let popoverEl: HTMLElement | undefined = $state();
36
+ let inputEl: HTMLInputElement | undefined = $state();
37
+
38
+ // Options resolved by `searchFn` for the current query; null while no
39
+ // server search has run yet (e.g. box just opened, query still empty).
40
+ let remoteResults = $state<{ value: string; label: string }[] | null>(null);
41
+ let searching = $state(false);
42
+ // Label of whatever was last picked from `remoteResults`, kept around so the
43
+ // closed-state input can still show it even though it isn't in `options`.
44
+ let pickedLabel = $state<string | null>(null);
45
+
46
+ let searchToken = 0;
47
+ $effect(() => {
48
+ if (!searchFn) return;
49
+ const q = query.trim();
50
+ if (!q) {
51
+ remoteResults = null;
52
+ searching = false;
53
+ return;
54
+ }
55
+ const token = ++searchToken;
56
+ searching = true;
57
+ const timer = setTimeout(() => {
58
+ searchFn(q).then((results) => {
59
+ if (token !== searchToken) return; // stale response, a newer query took over
60
+ remoteResults = results;
61
+ searching = false;
62
+ });
63
+ }, searchDebounceMs);
64
+ return () => clearTimeout(timer);
65
+ });
66
+
67
+ const filtered = $derived(
68
+ searchFn
69
+ ? (remoteResults ?? options)
70
+ : query.trim()
71
+ ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
72
+ : options
73
+ );
74
+
75
+ const selectedLabel = $derived(
76
+ value === '' ? '' : (options.find((o) => o.value === value)?.label ?? pickedLabel ?? '')
77
+ );
78
+
79
+ function openPopover() {
80
+ open = true;
81
+ query = '';
82
+ popoverEl?.showPopover();
83
+ }
84
+
85
+ function pick(option: { value: string; label: string }) {
86
+ value = option.value;
87
+ pickedLabel = option.label;
88
+ query = '';
89
+ popoverEl?.hidePopover();
90
+ inputEl?.blur();
91
+ }
92
+
93
+ function clear() {
94
+ value = '';
95
+ pickedLabel = null;
96
+ query = '';
97
+ popoverEl?.hidePopover();
98
+ inputEl?.blur();
99
+ }
100
+
101
+ // Catches dismissal paths that don't go through pick()/clear() (outside
102
+ // click, Escape), so a stale search doesn't linger for next time it opens.
103
+ function onToggle(e: Event) {
104
+ if ((e as ToggleEvent).newState === 'closed') {
105
+ open = false;
106
+ query = '';
107
+ }
108
+ }
109
+
110
+ // Escape can be pressed while an option button has focus (not just the
111
+ // text input), so this is wired at the container level rather than on
112
+ // the input alone — otherwise picking an option and then hitting Escape
113
+ // would leave the popover open with nothing listening for the key.
114
+ function onKeydown(e: KeyboardEvent) {
115
+ if (e.key === 'Enter' && e.target === inputEl) {
116
+ // The trigger used to be a plain button, so Enter never submitted the
117
+ // surrounding form. Now that it's a text input, guard against that.
118
+ e.preventDefault();
119
+ } else if (e.key === 'Escape') {
120
+ // Also suppress the browser's native "revert to last value" behavior
121
+ // for text inputs on Escape, which would otherwise race our own
122
+ // close-and-restore-label update and blank the field.
123
+ e.preventDefault();
124
+ popoverEl?.hidePopover();
125
+ }
126
+ }
127
+
128
+ // The trigger is a text input now, not a button, so it can't rely on the
129
+ // native `popovertarget` invoker exclusion to survive its own opening
130
+ // click (that relationship only applies to button-like elements — for a
131
+ // text input, the click that focuses it and opens the popover would
132
+ // immediately light-dismiss it again). `popover="manual"` opts out of
133
+ // that dismiss behavior entirely; this handler reimplements "close when
134
+ // focus leaves the widget" instead.
135
+ function onFocusOut(e: FocusEvent) {
136
+ const next = e.relatedTarget as Node | null;
137
+ if (!next || !containerEl?.contains(next)) {
138
+ popoverEl?.hidePopover();
139
+ }
140
+ }
141
+ </script>
142
+
143
+ <!--
144
+ The keydown/focusout handlers here are event delegation for the input and
145
+ popover option buttons below, not interaction with this div itself — every
146
+ actually-interactive element inside already has correct native semantics.
147
+ -->
148
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
149
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
150
+ <div class="relative w-full" bind:this={containerEl} onfocusout={onFocusOut} onkeydown={onKeydown}>
151
+ {#if name}
152
+ <input type="hidden" {name} {value} />
153
+ {/if}
154
+
155
+ <input
156
+ bind:this={inputEl}
157
+ {id}
158
+ type="text"
159
+ class="input input-bordered w-full font-normal"
160
+ class:input-error={!!error}
161
+ {disabled}
162
+ {placeholder}
163
+ value={open ? query : selectedLabel}
164
+ oninput={(e) => (query = e.currentTarget.value)}
165
+ onfocus={openPopover}
166
+ style="anchor-name:{anchorName}"
167
+ autocomplete="off"
168
+ />
169
+
170
+ <div
171
+ popover="manual"
172
+ id={popId}
173
+ bind:this={popoverEl}
174
+ style="position-anchor:{anchorName}; width:anchor-size(width); position-try-fallbacks:flip-block;"
175
+ class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
176
+ ontoggle={onToggle}
177
+ >
178
+ <ul class="max-h-48 overflow-y-auto p-1">
179
+ <li>
180
+ <button
181
+ type="button"
182
+ class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
183
+ onclick={clear}
184
+ >
185
+ {placeholder}
186
+ </button>
187
+ </li>
188
+ {#if searching}
189
+ <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
190
+ {/if}
191
+ {#each filtered as option (option.value)}
192
+ <li>
193
+ <button
194
+ type="button"
195
+ class="w-full rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
196
+ class:bg-primary={value === option.value}
197
+ class:text-primary-content={value === option.value}
198
+ onclick={() => pick(option)}
199
+ >
200
+ {option.label}
201
+ </button>
202
+ </li>
203
+ {/each}
204
+ {#if !searching && filtered.length === 0}
205
+ <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
206
+ {/if}
207
+ </ul>
208
+ </div>
209
+ </div>
@@ -1,62 +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>
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>
@@ -1,66 +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>
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>