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.
@@ -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.tooltip ?? bulkAction.label}
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
+ &nbsp;({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
+ &nbsp;({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
- onSuccess?.();
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
- <Button type="submit" variant="primary">
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>
@@ -11,6 +11,7 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
11
11
  serverError?: string;
12
12
  onCancel?: () => void;
13
13
  onSuccess?: () => void;
14
+ onContinue?: () => void;
14
15
  };
15
16
  exports: {};
16
17
  bindings: "";
@@ -1,198 +1,248 @@
1
1
  <script lang="ts">
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
- value = $bindable([]),
15
- options = [],
16
- search: searchFn,
17
- searchDebounceMs = 300,
18
- placeholder = strings.selectPlaceholder,
19
- error = '',
20
- disabled = false,
21
- // When passed, the dropdown's top row selects every option instead of
22
- // clearing the selection (labelled `strings.selectAll` instead of
23
- // `placeholder`) — clearing moves to the trigger's own × button instead.
24
- // Left unset, existing callers keep today's behavior unchanged: top row
25
- // clears, no × button.
26
- onSelectAll,
27
- }: {
28
- name?: string;
29
- value?: string[];
30
- options?: SelectOption[];
31
- search?: SearchResolver;
32
- searchDebounceMs?: number;
33
- placeholder?: string;
34
- error?: string;
35
- disabled?: boolean;
36
- onSelectAll?: () => void;
37
- } = $props();
38
-
39
- const popId = $props.id();
40
- const anchorName = `--multiselect-anchor-${popId}`;
41
-
42
- let query = $state('');
43
- let popoverEl: HTMLElement | undefined = $state();
44
-
45
- let remoteResults = $state<SelectOption[] | null>(null);
46
- let searching = $state(false);
47
- const pickedLabels = new SvelteMap<string, string>();
48
-
49
- let searchToken = 0;
50
- $effect(() => {
51
- if (!searchFn) return;
52
- const q = query.trim();
53
- if (!q) {
54
- remoteResults = null;
55
- searching = false;
56
- return;
57
- }
58
- const token = ++searchToken;
59
- searching = true;
60
- const timer = setTimeout(() => {
61
- searchFn(q).then((results) => {
62
- if (token !== searchToken) return; // stale response, a newer query took over
63
- remoteResults = results;
64
- searching = false;
65
- });
66
- }, searchDebounceMs);
67
- return () => clearTimeout(timer);
68
- });
69
-
70
- const filtered = $derived(
71
- searchFn
72
- ? (remoteResults ?? options)
73
- : query.trim()
74
- ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
75
- : options
76
- );
77
-
78
- const selectedLabels = $derived(
79
- value.map((v) => options.find((o) => o.value === v)?.label ?? pickedLabels.get(v) ?? v)
80
- );
81
- const buttonLabel = $derived(value.length === 0 ? placeholder : strings.selectedCount(value.length));
82
-
83
- function toggle(option: SelectOption) {
84
- if (value.includes(option.value)) {
85
- value = value.filter((v) => v !== option.value);
86
- } else {
87
- value = [...value, option.value];
88
- pickedLabels.set(option.value, option.label);
89
- }
90
- }
91
-
92
- function clear() {
93
- value = [];
94
- pickedLabels.clear();
95
- query = '';
96
- }
97
-
98
- function onToggle(e: Event) {
99
- if ((e as ToggleEvent).newState === 'closed') query = '';
100
- }
101
-
102
- const hasSelection = $derived(value.length > 0);
103
-
104
- function clearFromTrigger(e: MouseEvent) {
105
- // Stop the click from also reaching the popover-trigger button
106
- // underneath (they're overlapping siblings, not nested) so clearing
107
- // doesn't toggle the dropdown open at the same time.
108
- e.preventDefault();
109
- e.stopPropagation();
110
- clear();
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
- <div class="relative w-full">
115
- {#if name}
116
- <input type="hidden" {name} value={JSON.stringify(value)} />
117
- {/if}
118
-
119
- <div class="relative">
120
- <button
121
- type="button"
122
- class="select select-bordered w-full text-left font-normal"
123
- class:select-error={!!error}
124
- class:opacity-40={value.length === 0}
125
- class:pr-8={hasSelection}
126
- {disabled}
127
- popovertarget={popId}
128
- style="anchor-name:{anchorName}"
129
- title={selectedLabels.join(', ')}
130
- >
131
- {buttonLabel}
132
- </button>
133
-
134
- {#if hasSelection && !disabled}
135
- {@const Icon = icons.clear}
136
- <Button
137
- variant="ghost"
138
- class="btn-xs btn-square btn-circle absolute top-1/2 right-6 -translate-y-1/2"
139
- aria-label={strings.selectClear}
140
- title={strings.selectClear}
141
- onclick={clearFromTrigger}
142
- >
143
- <Icon class="size-3" />
144
- </Button>
145
- {/if}
146
- </div>
147
-
148
- <div
149
- popover="auto"
150
- id={popId}
151
- bind:this={popoverEl}
152
- style="position-anchor:{anchorName}; width:anchor-size(width); position-try-fallbacks:flip-block;"
153
- class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
154
- ontoggle={onToggle}
155
- >
156
- <div class="p-2">
157
- <input
158
- type="text"
159
- class="input input-bordered input-sm w-full"
160
- placeholder={strings.selectSearch}
161
- bind:value={query}
162
- autocomplete="off"
163
- />
164
- </div>
165
- <ul class="max-h-48 overflow-y-auto p-1">
166
- <li>
167
- <button
168
- type="button"
169
- class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
170
- onclick={onSelectAll ?? clear}
171
- >
172
- {onSelectAll ? strings.selectAll : placeholder}
173
- </button>
174
- </li>
175
- {#if searching}
176
- <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
177
- {/if}
178
- {#each filtered as option (option.value)}
179
- {@const checked = value.includes(option.value)}
180
- <li>
181
- <button
182
- type="button"
183
- class="flex w-full items-center gap-2 rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
184
- class:bg-primary={checked}
185
- class:text-primary-content={checked}
186
- onclick={() => toggle(option)}
187
- >
188
- <input type="checkbox" class="checkbox checkbox-sm" {checked} tabindex="-1" readonly />
189
- {option.label}
190
- </button>
191
- </li>
192
- {/each}
193
- {#if !searching && filtered.length === 0}
194
- <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
195
- {/if}
196
- </ul>
197
- </div>
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>
@@ -1,6 +1,7 @@
1
1
  import type { SearchResolver, SelectOption } from '../../types/attribute.js';
2
2
  type $$ComponentProps = {
3
3
  name?: string;
4
+ id?: string;
4
5
  value?: string[];
5
6
  options?: SelectOption[];
6
7
  search?: SearchResolver;
@@ -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
- {value}
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
- 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
- value = $bindable(''),
10
- options = [],
11
- search: searchFn,
12
- searchDebounceMs = 300,
13
- placeholder = strings.selectPlaceholder,
14
- error = '',
15
- disabled = false,
16
- }: {
17
- name?: string;
18
- value?: string;
19
- options?: { value: string; label: string }[];
20
- search?: SearchResolver;
21
- searchDebounceMs?: number;
22
- placeholder?: string;
23
- error?: string;
24
- disabled?: boolean;
25
- } = $props();
26
-
27
- const popId = $props.id();
28
- const anchorName = `--select-anchor-${popId}`;
29
-
30
- let query = $state('');
31
- let popoverEl: HTMLElement | undefined = $state();
32
-
33
- // Options resolved by `searchFn` for the current query; null while no
34
- // server search has run yet (e.g. box just opened, query still empty).
35
- let remoteResults = $state<{ value: string; label: string }[] | null>(null);
36
- let searching = $state(false);
37
- // Label of whatever was last picked from `remoteResults`, kept around so the
38
- // closed-state button can still show it even though it isn't in `options`.
39
- let pickedLabel = $state<string | null>(null);
40
-
41
- let searchToken = 0;
42
- $effect(() => {
43
- if (!searchFn) return;
44
- const q = query.trim();
45
- if (!q) {
46
- remoteResults = null;
47
- searching = false;
48
- return;
49
- }
50
- const token = ++searchToken;
51
- searching = true;
52
- const timer = setTimeout(() => {
53
- searchFn(q).then((results) => {
54
- if (token !== searchToken) return; // stale response, a newer query took over
55
- remoteResults = results;
56
- searching = false;
57
- });
58
- }, searchDebounceMs);
59
- return () => clearTimeout(timer);
60
- });
61
-
62
- const filtered = $derived(
63
- searchFn
64
- ? (remoteResults ?? options)
65
- : query.trim()
66
- ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
67
- : options
68
- );
69
-
70
- const selectedLabel = $derived(
71
- value === ''
72
- ? placeholder
73
- : (options.find((o) => o.value === value)?.label ?? pickedLabel ?? placeholder)
74
- );
75
-
76
- function pick(option: { value: string; label: string }) {
77
- value = option.value;
78
- pickedLabel = option.label;
79
- query = '';
80
- popoverEl?.hidePopover();
81
- }
82
-
83
- function clear() {
84
- value = '';
85
- pickedLabel = null;
86
- query = '';
87
- popoverEl?.hidePopover();
88
- }
89
-
90
- // Catches dismissal paths that don't go through pick()/clear() (outside
91
- // click, Escape), so a stale search doesn't linger for next time it opens.
92
- function onToggle(e: Event) {
93
- if ((e as ToggleEvent).newState === 'closed') query = '';
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
- <div class="relative w-full">
98
- {#if name}
99
- <input type="hidden" {name} {value} />
100
- {/if}
101
-
102
- <button
103
- type="button"
104
- class="select select-bordered w-full text-left font-normal"
105
- class:select-error={!!error}
106
- class:opacity-40={!value}
107
- {disabled}
108
- popovertarget={popId}
109
- style="anchor-name:{anchorName}"
110
- >
111
- {selectedLabel}
112
- </button>
113
-
114
- <div
115
- popover="auto"
116
- id={popId}
117
- bind:this={popoverEl}
118
- style="position-anchor:{anchorName}; width:anchor-size(width); position-try-fallbacks:flip-block;"
119
- class="dropdown mt-1 rounded-box border border-base-content/10 bg-base-100 shadow-lg"
120
- ontoggle={onToggle}
121
- >
122
- <div class="p-2">
123
- <input
124
- type="text"
125
- class="input input-bordered input-sm w-full"
126
- placeholder={strings.selectSearch}
127
- bind:value={query}
128
- autocomplete="off"
129
- />
130
- </div>
131
- <ul class="max-h-48 overflow-y-auto p-1">
132
- <li>
133
- <button
134
- type="button"
135
- class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
136
- onclick={clear}
137
- >
138
- {placeholder}
139
- </button>
140
- </li>
141
- {#if searching}
142
- <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
143
- {/if}
144
- {#each filtered as option (option.value)}
145
- <li>
146
- <button
147
- type="button"
148
- class="w-full rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
149
- class:bg-primary={value === option.value}
150
- class:text-primary-content={value === option.value}
151
- onclick={() => pick(option)}
152
- >
153
- {option.label}
154
- </button>
155
- </li>
156
- {/each}
157
- {#if !searching && filtered.length === 0}
158
- <li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
159
- {/if}
160
- </ul>
161
- </div>
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>
@@ -1,6 +1,7 @@
1
1
  import type { SearchResolver } from '../../types/attribute.js';
2
2
  type $$ComponentProps = {
3
3
  name?: string;
4
+ id?: string;
4
5
  value?: string;
5
6
  options?: {
6
7
  value: string;
@@ -48,7 +48,7 @@
48
48
  }
49
49
 
50
50
  const pages = $derived(buildPages(page, totalPages));
51
- const inputWidth = $derived(`${String(totalPages).length + 2}ch`);
51
+ const inputWidth = $derived(`${String(totalPages).length + 4}ch`);
52
52
 
53
53
  function handlePageInput(e: KeyboardEvent) {
54
54
  if (e.key !== 'Enter') return;
@@ -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
- /** Create form only: the "Save and continue" button, which submits to the
75
- * same `endpoint` and then blanks the form so the user can create another
76
- * record from scratch. Enabled by default — pass `{ enabled: false }` to
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "description": "SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",