runeforge 0.0.47 → 0.0.49
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/dist/components/crud/Field.svelte +3 -5
- package/dist/components/crud/GenericCRUD.svelte +16 -0
- package/dist/components/crud/views/List.svelte +3 -3
- package/dist/components/crud/views/Update.svelte +24 -2
- package/dist/components/crud/views/Update.svelte.d.ts +1 -0
- package/dist/components/form/MultiSelect.svelte +244 -194
- package/dist/components/form/MultiSelect.svelte.d.ts +1 -0
- package/dist/components/form/PasswordInput.svelte +2 -2
- package/dist/components/form/PasswordInput.svelte.d.ts +1 -1
- package/dist/components/form/Select.svelte +205 -158
- package/dist/components/form/Select.svelte.d.ts +1 -0
- package/dist/components/table/Paginator.svelte +1 -1
- package/dist/types/crud.d.ts +9 -4
- package/package.json +1 -1
|
@@ -199,6 +199,7 @@
|
|
|
199
199
|
/>
|
|
200
200
|
{:else}
|
|
201
201
|
<Select
|
|
202
|
+
id={field.attribute}
|
|
202
203
|
name={field.attribute}
|
|
203
204
|
bind:value={record[field.attribute] as string}
|
|
204
205
|
options={selectOptions}
|
|
@@ -250,11 +251,7 @@
|
|
|
250
251
|
style="position-anchor:{dateAnchorName}; position-try-fallbacks:flip-block;"
|
|
251
252
|
class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 p-2 shadow-lg"
|
|
252
253
|
>
|
|
253
|
-
<calendar-date
|
|
254
|
-
class="cally"
|
|
255
|
-
bind:this={calendarDateEl}
|
|
256
|
-
value={datePart}
|
|
257
|
-
>
|
|
254
|
+
<calendar-date class="cally" bind:this={calendarDateEl} value={datePart}>
|
|
258
255
|
<svg
|
|
259
256
|
aria-label={strings.previous}
|
|
260
257
|
class="fill-current size-4"
|
|
@@ -309,6 +306,7 @@
|
|
|
309
306
|
/>
|
|
310
307
|
{:else}
|
|
311
308
|
<MultiSelect
|
|
309
|
+
id={field.attribute}
|
|
312
310
|
name={field.attribute}
|
|
313
311
|
bind:value={record[field.attribute] as string[]}
|
|
314
312
|
options={selectOptions}
|
|
@@ -154,6 +154,21 @@
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// "Save and continue" on the Update form: jumps to editing the record that
|
|
158
|
+
// follows the current one in the currently loaded list, falling back to
|
|
159
|
+
// re-editing the same instance when it's the last one (or isn't found).
|
|
160
|
+
function nextInstance(current: T): T {
|
|
161
|
+
const currentId = (current as Record<string, unknown>)[idKey];
|
|
162
|
+
const idx = entityData.findIndex(
|
|
163
|
+
(item) => (item as Record<string, unknown>)[idKey] === currentId
|
|
164
|
+
);
|
|
165
|
+
return (idx !== -1 ? entityData[idx + 1] : undefined) ?? current;
|
|
166
|
+
}
|
|
167
|
+
async function navContinueEdit() {
|
|
168
|
+
if (!singleInstance) return;
|
|
169
|
+
await navEdit(nextInstance(singleInstance));
|
|
170
|
+
}
|
|
171
|
+
|
|
157
172
|
const resolvedColumns: ColumnDefinition<T>[] = $derived(
|
|
158
173
|
columns ??
|
|
159
174
|
(meta
|
|
@@ -341,6 +356,7 @@
|
|
|
341
356
|
{serverError}
|
|
342
357
|
onCancel={navList}
|
|
343
358
|
onSuccess={navList}
|
|
359
|
+
onContinue={navContinueEdit}
|
|
344
360
|
/>
|
|
345
361
|
{:else}
|
|
346
362
|
<List
|
|
@@ -328,13 +328,13 @@
|
|
|
328
328
|
class="btn-outline"
|
|
329
329
|
disabled={!isView && selected.size === 0}
|
|
330
330
|
title={bulkAction.tooltip ?? bulkAction.label}
|
|
331
|
-
aria-label={bulkAction.
|
|
331
|
+
aria-label={bulkAction.label ? undefined : bulkAction.tooltip}
|
|
332
332
|
onclick={measuring ? undefined : () => handleBulkAction(bulkAction)}
|
|
333
333
|
>
|
|
334
334
|
<BulkIcon class="size-4" />
|
|
335
335
|
{#if bulkAction.label}
|
|
336
336
|
{bulkAction.label}{#if !isView}
|
|
337
|
-
({selected.size})
|
|
337
|
+
({selected.size})
|
|
338
338
|
{/if}
|
|
339
339
|
{/if}
|
|
340
340
|
</Button>
|
|
@@ -505,7 +505,7 @@
|
|
|
505
505
|
>
|
|
506
506
|
<BulkIcon class="size-4" />
|
|
507
507
|
{bulkAction.label ?? bulkAction.tooltip}{#if !isView}
|
|
508
|
-
({selected.size})
|
|
508
|
+
({selected.size})
|
|
509
509
|
{/if}
|
|
510
510
|
</Button>
|
|
511
511
|
{/if}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
serverError = '',
|
|
26
26
|
onCancel,
|
|
27
27
|
onSuccess,
|
|
28
|
+
onContinue,
|
|
28
29
|
}: {
|
|
29
30
|
labelOne?: string;
|
|
30
31
|
labelMany?: string;
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
serverError?: string;
|
|
38
39
|
onCancel?: () => void;
|
|
39
40
|
onSuccess?: () => void;
|
|
41
|
+
onContinue?: () => void;
|
|
40
42
|
} = $props();
|
|
41
43
|
|
|
42
44
|
const icons = $derived(getIconSet() ?? defaultIconSet);
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
|
|
45
47
|
let fieldErrors = $state<Record<string, string>>({});
|
|
46
48
|
let internalError = $state('');
|
|
49
|
+
let continuing = $state(false);
|
|
47
50
|
|
|
48
51
|
function seedFromInstance(inst: Record<string, unknown>): Record<string, unknown> {
|
|
49
52
|
const seeded: Record<string, unknown> = { ...inst };
|
|
@@ -67,6 +70,10 @@
|
|
|
67
70
|
const groups = $derived(groupFields(fields));
|
|
68
71
|
const hasFileField = $derived(fields.some((f) => f.type === 'file'));
|
|
69
72
|
|
|
73
|
+
const continueEnabled = $derived(update.continue?.enabled ?? false);
|
|
74
|
+
const continueLabel = $derived(update.continue?.label ?? strings.saveAndContinue);
|
|
75
|
+
const continueClass = $derived(update.continue?.class ?? '');
|
|
76
|
+
|
|
70
77
|
const errorEntries = $derived([
|
|
71
78
|
...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
|
|
72
79
|
...Object.entries(fieldErrors),
|
|
@@ -113,7 +120,12 @@
|
|
|
113
120
|
return async ({ result, update: updateForm }) => {
|
|
114
121
|
if (result.type === 'success' || result.type === 'redirect') {
|
|
115
122
|
await updateForm({ reset: false });
|
|
116
|
-
|
|
123
|
+
if (continuing) {
|
|
124
|
+
continuing = false;
|
|
125
|
+
onContinue?.();
|
|
126
|
+
} else {
|
|
127
|
+
onSuccess?.();
|
|
128
|
+
}
|
|
117
129
|
} else if (result.type === 'error') {
|
|
118
130
|
internalError = result.error?.message ?? strings.serverError;
|
|
119
131
|
} else {
|
|
@@ -161,7 +173,17 @@
|
|
|
161
173
|
<Button variant="ghost" onclick={() => onCancel?.()}>
|
|
162
174
|
{strings.cancel}
|
|
163
175
|
</Button>
|
|
164
|
-
|
|
176
|
+
{#if continueEnabled}
|
|
177
|
+
<Button
|
|
178
|
+
type="submit"
|
|
179
|
+
variant="secondary"
|
|
180
|
+
class={continueClass}
|
|
181
|
+
onclick={() => { continuing = true; }}
|
|
182
|
+
>
|
|
183
|
+
{continueLabel}
|
|
184
|
+
</Button>
|
|
185
|
+
{/if}
|
|
186
|
+
<Button type="submit" variant="primary" onclick={() => { continuing = false; }}>
|
|
165
187
|
{strings.save}
|
|
166
188
|
</Button>
|
|
167
189
|
</div>
|
|
@@ -1,198 +1,248 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
2
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
3
|
+
import { getStrings } from '../../i18n/context.js';
|
|
4
|
+
import { getIconSet } from '../../icons/context.js';
|
|
5
|
+
import { defaultIconSet } from '../../icons/sets/default.js';
|
|
6
|
+
import Button from './Button.svelte';
|
|
7
|
+
import type { SearchResolver, SelectOption } from '../../types/attribute.js';
|
|
8
|
+
|
|
9
|
+
const strings = getStrings();
|
|
10
|
+
const icons = $derived(getIconSet() ?? defaultIconSet);
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
name,
|
|
14
|
+
id,
|
|
15
|
+
value = $bindable([]),
|
|
16
|
+
options = [],
|
|
17
|
+
search: searchFn,
|
|
18
|
+
searchDebounceMs = 300,
|
|
19
|
+
placeholder = strings.selectPlaceholder,
|
|
20
|
+
error = '',
|
|
21
|
+
disabled = false,
|
|
22
|
+
// When passed, the dropdown's top row selects every option instead of
|
|
23
|
+
// clearing the selection (labelled `strings.selectAll` instead of
|
|
24
|
+
// `placeholder`) — clearing moves to the trigger's own × button instead.
|
|
25
|
+
// Left unset, existing callers keep today's behavior unchanged: top row
|
|
26
|
+
// clears, no × button.
|
|
27
|
+
onSelectAll
|
|
28
|
+
}: {
|
|
29
|
+
name?: string;
|
|
30
|
+
id?: string;
|
|
31
|
+
value?: string[];
|
|
32
|
+
options?: SelectOption[];
|
|
33
|
+
search?: SearchResolver;
|
|
34
|
+
searchDebounceMs?: number;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
onSelectAll?: () => void;
|
|
39
|
+
} = $props();
|
|
40
|
+
|
|
41
|
+
const popId = $props.id();
|
|
42
|
+
const anchorName = `--multiselect-anchor-${popId}`;
|
|
43
|
+
|
|
44
|
+
let query = $state('');
|
|
45
|
+
let open = $state(false);
|
|
46
|
+
let containerEl: HTMLElement | undefined = $state();
|
|
47
|
+
let popoverEl: HTMLElement | undefined = $state();
|
|
48
|
+
let inputEl: HTMLInputElement | undefined = $state();
|
|
49
|
+
|
|
50
|
+
let remoteResults = $state<SelectOption[] | null>(null);
|
|
51
|
+
let searching = $state(false);
|
|
52
|
+
const pickedLabels = new SvelteMap<string, string>();
|
|
53
|
+
|
|
54
|
+
let searchToken = 0;
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (!searchFn) return;
|
|
57
|
+
const q = query.trim();
|
|
58
|
+
if (!q) {
|
|
59
|
+
remoteResults = null;
|
|
60
|
+
searching = false;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const token = ++searchToken;
|
|
64
|
+
searching = true;
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
searchFn(q).then((results) => {
|
|
67
|
+
if (token !== searchToken) return; // stale response, a newer query took over
|
|
68
|
+
remoteResults = results;
|
|
69
|
+
searching = false;
|
|
70
|
+
});
|
|
71
|
+
}, searchDebounceMs);
|
|
72
|
+
return () => clearTimeout(timer);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const filtered = $derived(
|
|
76
|
+
searchFn
|
|
77
|
+
? (remoteResults ?? options)
|
|
78
|
+
: query.trim()
|
|
79
|
+
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
|
80
|
+
: options
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const selectedLabels = $derived(
|
|
84
|
+
value.map((v) => options.find((o) => o.value === v)?.label ?? pickedLabels.get(v) ?? v)
|
|
85
|
+
);
|
|
86
|
+
const buttonLabel = $derived(
|
|
87
|
+
value.length === 0 ? placeholder : strings.selectedCount(value.length)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
function toggle(option: SelectOption) {
|
|
91
|
+
if (value.includes(option.value)) {
|
|
92
|
+
value = value.filter((v) => v !== option.value);
|
|
93
|
+
} else {
|
|
94
|
+
value = [...value, option.value];
|
|
95
|
+
pickedLabels.set(option.value, option.label);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function openPopover() {
|
|
100
|
+
open = true;
|
|
101
|
+
query = '';
|
|
102
|
+
popoverEl?.showPopover();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clear() {
|
|
106
|
+
value = [];
|
|
107
|
+
pickedLabels.clear();
|
|
108
|
+
query = '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onToggle(e: Event) {
|
|
112
|
+
if ((e as ToggleEvent).newState === 'closed') {
|
|
113
|
+
open = false;
|
|
114
|
+
query = '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Escape can be pressed while an option button has focus (not just the
|
|
119
|
+
// text input), so this is wired at the container level rather than on
|
|
120
|
+
// the input alone — otherwise picking an option and then hitting Escape
|
|
121
|
+
// would leave the popover open with nothing listening for the key.
|
|
122
|
+
function onKeydown(e: KeyboardEvent) {
|
|
123
|
+
if (e.key === 'Enter' && e.target === inputEl) {
|
|
124
|
+
// The trigger used to be a plain button, so Enter never submitted the
|
|
125
|
+
// surrounding form. Now that it's a text input, guard against that.
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
} else if (e.key === 'Escape') {
|
|
128
|
+
// Also suppress the browser's native "revert to last value" behavior
|
|
129
|
+
// for text inputs on Escape, which would otherwise race our own
|
|
130
|
+
// close-and-restore-label update and blank the field.
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
popoverEl?.hidePopover();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hasSelection = $derived(value.length > 0);
|
|
137
|
+
|
|
138
|
+
function clearFromTrigger(e: MouseEvent) {
|
|
139
|
+
// Stop the click from also reaching the input underneath (they're
|
|
140
|
+
// overlapping siblings, not nested) so clearing doesn't focus/open the
|
|
141
|
+
// dropdown at the same time.
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
clear();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The trigger is a text input now, not a button, so it can't rely on the
|
|
148
|
+
// native `popovertarget` invoker exclusion to survive its own opening
|
|
149
|
+
// click (that relationship only applies to button-like elements — for a
|
|
150
|
+
// text input, the click that focuses it and opens the popover would
|
|
151
|
+
// immediately light-dismiss it again). `popover="manual"` opts out of
|
|
152
|
+
// that dismiss behavior entirely; this handler reimplements "close when
|
|
153
|
+
// focus leaves the widget" instead.
|
|
154
|
+
function onFocusOut(e: FocusEvent) {
|
|
155
|
+
const next = e.relatedTarget as Node | null;
|
|
156
|
+
if (!next || !containerEl?.contains(next)) {
|
|
157
|
+
popoverEl?.hidePopover();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
112
160
|
</script>
|
|
113
161
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
162
|
+
<!--
|
|
163
|
+
The keydown/focusout handlers here are event delegation for the input and
|
|
164
|
+
popover option buttons below, not interaction with this div itself — every
|
|
165
|
+
actually-interactive element inside already has correct native semantics.
|
|
166
|
+
-->
|
|
167
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
168
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
169
|
+
<div class="relative w-full" bind:this={containerEl} onfocusout={onFocusOut} onkeydown={onKeydown}>
|
|
170
|
+
{#if name}
|
|
171
|
+
<input type="hidden" {name} value={JSON.stringify(value)} />
|
|
172
|
+
{/if}
|
|
173
|
+
|
|
174
|
+
<div class="relative">
|
|
175
|
+
<input
|
|
176
|
+
bind:this={inputEl}
|
|
177
|
+
{id}
|
|
178
|
+
type="text"
|
|
179
|
+
class="input input-bordered w-full font-normal"
|
|
180
|
+
class:input-error={!!error}
|
|
181
|
+
class:opacity-40={value.length === 0 && !open}
|
|
182
|
+
class:pr-8={hasSelection}
|
|
183
|
+
{disabled}
|
|
184
|
+
{placeholder}
|
|
185
|
+
value={open ? query : buttonLabel}
|
|
186
|
+
oninput={(e) => (query = e.currentTarget.value)}
|
|
187
|
+
onfocus={openPopover}
|
|
188
|
+
style="anchor-name:{anchorName}"
|
|
189
|
+
title={selectedLabels.join(', ')}
|
|
190
|
+
autocomplete="off"
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
{#if hasSelection && !disabled}
|
|
194
|
+
{@const Icon = icons.clear}
|
|
195
|
+
<Button
|
|
196
|
+
variant="ghost"
|
|
197
|
+
class="btn-xs btn-square btn-circle absolute top-1/2 right-6 -translate-y-1/2"
|
|
198
|
+
aria-label={strings.selectClear}
|
|
199
|
+
title={strings.selectClear}
|
|
200
|
+
onclick={clearFromTrigger}
|
|
201
|
+
>
|
|
202
|
+
<Icon class="size-3" />
|
|
203
|
+
</Button>
|
|
204
|
+
{/if}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div
|
|
208
|
+
popover="manual"
|
|
209
|
+
id={popId}
|
|
210
|
+
bind:this={popoverEl}
|
|
211
|
+
style="position-anchor:{anchorName}; width:anchor-size(width); position-try-fallbacks:flip-block;"
|
|
212
|
+
class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
|
|
213
|
+
ontoggle={onToggle}
|
|
214
|
+
>
|
|
215
|
+
<ul class="max-h-48 overflow-y-auto p-1">
|
|
216
|
+
<li>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
|
|
220
|
+
onclick={onSelectAll ?? clear}
|
|
221
|
+
>
|
|
222
|
+
{onSelectAll ? strings.selectAll : placeholder}
|
|
223
|
+
</button>
|
|
224
|
+
</li>
|
|
225
|
+
{#if searching}
|
|
226
|
+
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
|
|
227
|
+
{/if}
|
|
228
|
+
{#each filtered as option (option.value)}
|
|
229
|
+
{@const checked = value.includes(option.value)}
|
|
230
|
+
<li>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
class="flex w-full items-center gap-2 rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
|
|
234
|
+
class:bg-primary={checked}
|
|
235
|
+
class:text-primary-content={checked}
|
|
236
|
+
onclick={() => toggle(option)}
|
|
237
|
+
>
|
|
238
|
+
<input type="checkbox" class="checkbox checkbox-sm" {checked} tabindex="-1" readonly />
|
|
239
|
+
{option.label}
|
|
240
|
+
</button>
|
|
241
|
+
</li>
|
|
242
|
+
{/each}
|
|
243
|
+
{#if !searching && filtered.length === 0}
|
|
244
|
+
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
|
|
245
|
+
{/if}
|
|
246
|
+
</ul>
|
|
247
|
+
</div>
|
|
198
248
|
</div>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
let {
|
|
9
9
|
name,
|
|
10
10
|
id,
|
|
11
|
-
value = '',
|
|
11
|
+
value = $bindable(''),
|
|
12
12
|
autocomplete,
|
|
13
13
|
required = true,
|
|
14
14
|
placeholder = '',
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
{id}
|
|
46
46
|
{required}
|
|
47
47
|
{placeholder}
|
|
48
|
-
|
|
48
|
+
bind:value
|
|
49
49
|
{autocomplete}
|
|
50
50
|
type={visible ? 'text' : 'password'}
|
|
51
51
|
class={['grow', inputClass]}
|
|
@@ -11,6 +11,6 @@ type $$ComponentProps = {
|
|
|
11
11
|
inputClass?: string;
|
|
12
12
|
buttonClass?: string;
|
|
13
13
|
};
|
|
14
|
-
declare const PasswordInput: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
declare const PasswordInput: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
15
15
|
type PasswordInput = ReturnType<typeof PasswordInput>;
|
|
16
16
|
export default PasswordInput;
|
|
@@ -1,162 +1,209 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
+
}
|
|
95
141
|
</script>
|
|
96
142
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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>
|
|
162
209
|
</div>
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const pages = $derived(buildPages(page, totalPages));
|
|
51
|
-
const inputWidth = $derived(`${String(totalPages).length +
|
|
51
|
+
const inputWidth = $derived(`${String(totalPages).length + 4}ch`);
|
|
52
52
|
|
|
53
53
|
function handlePageInput(e: KeyboardEvent) {
|
|
54
54
|
if (e.key !== 'Enter') return;
|
package/dist/types/crud.d.ts
CHANGED
|
@@ -71,10 +71,15 @@ export interface ActionConfiguration<T extends object = Record<string, unknown>>
|
|
|
71
71
|
endpoint?: string;
|
|
72
72
|
confirm?: boolean;
|
|
73
73
|
callback?: (items: T[]) => void | Promise<void>;
|
|
74
|
-
/**
|
|
75
|
-
* same `endpoint` and then blanks the form
|
|
76
|
-
* record from scratch. Enabled by
|
|
77
|
-
* hide it.
|
|
74
|
+
/** The "Save and continue" button, shown alongside Save/Cancel.
|
|
75
|
+
* - Create form: submits to the same `endpoint` and then blanks the form
|
|
76
|
+
* so the user can create another record from scratch. Enabled by
|
|
77
|
+
* default — pass `{ enabled: false }` to hide it.
|
|
78
|
+
* - Update form: submits to the same `endpoint` and then loads the record
|
|
79
|
+
* that follows the current one in the list into the same form, so
|
|
80
|
+
* several records can be edited in a row — handy after a bulk import.
|
|
81
|
+
* Falls back to reloading the current instance when there is no next
|
|
82
|
+
* one. Disabled by default — pass `{ enabled: true }` to show it. */
|
|
78
83
|
continue?: CreateFormButtonConfiguration;
|
|
79
84
|
/** Create form only: shows a "Duplicate" button alongside Save/Cancel that
|
|
80
85
|
* submits to the same `endpoint`, but — unlike "Save and continue", which
|