runeforge 0.0.55 → 0.0.56
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/LICENSE +21 -21
- package/README.md +1342 -1342
- package/dist/components/Avatar.svelte +31 -31
- package/dist/components/IconRenderer.svelte +22 -22
- package/dist/components/Modal.svelte +75 -75
- package/dist/components/common/Header.svelte +40 -40
- package/dist/components/crud/EmbeddedField.svelte +175 -175
- package/dist/components/crud/Field.svelte +469 -376
- package/dist/components/crud/GenericCRUD.svelte +426 -426
- package/dist/components/crud/SearchInput.svelte +53 -53
- package/dist/components/crud/columns/Avatar.svelte +15 -15
- package/dist/components/crud/columns/Icon.svelte +8 -8
- package/dist/components/crud/views/Create.svelte +232 -232
- package/dist/components/crud/views/Read.svelte +124 -124
- package/dist/components/crud/views/Update.svelte +208 -208
- package/dist/components/crud/views/list/List.svelte +291 -291
- package/dist/components/crud/views/list/Modals.svelte +56 -56
- package/dist/components/crud/views/list/Table.svelte +176 -176
- package/dist/components/crud/views/list/Toolbar.svelte +341 -341
- package/dist/components/form/Button.svelte +27 -27
- package/dist/components/form/Label.svelte +37 -37
- package/dist/components/form/MultiSelect.svelte +248 -248
- package/dist/components/form/PasswordInput.svelte +68 -68
- package/dist/components/form/Required.svelte +1 -1
- package/dist/components/form/Select.svelte +209 -209
- package/dist/components/form/Tree.svelte +62 -62
- package/dist/components/form/TreeNode.svelte +66 -66
- package/dist/components/navigation/Breadcrumbs.svelte +111 -111
- package/dist/components/table/ColumnFilter.svelte +168 -168
- package/dist/components/table/PaginatedTable.svelte +536 -536
- package/dist/components/table/Paginator.svelte +113 -113
- package/dist/components/table/SortHeader.svelte +43 -43
- package/dist/components/table/TableBody.svelte +150 -150
- package/dist/components/table/TableHeader.svelte +88 -88
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/types.d.ts +1 -0
- package/dist/icons/defaults/Clear.svelte +6 -6
- package/dist/icons/defaults/Create.svelte +6 -6
- package/dist/icons/defaults/Delete.svelte +6 -6
- package/dist/icons/defaults/Download.svelte +7 -7
- package/dist/icons/defaults/Edit.svelte +7 -7
- package/dist/icons/defaults/Filter.svelte +6 -6
- package/dist/icons/defaults/FilterActive.svelte +6 -6
- package/dist/icons/defaults/Folder.svelte +6 -6
- package/dist/icons/defaults/Grip.svelte +7 -7
- package/dist/icons/defaults/Home.svelte +6 -6
- package/dist/icons/defaults/PasswordHide.svelte +9 -9
- package/dist/icons/defaults/PasswordShow.svelte +7 -7
- package/dist/icons/defaults/SortAsc.svelte +6 -6
- package/dist/icons/defaults/SortDesc.svelte +6 -6
- package/dist/icons/defaults/SortNone.svelte +6 -6
- package/dist/icons/defaults/View.svelte +7 -7
- package/package.json +1 -1
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { FullAutoFill } from 'svelte/elements';
|
|
3
|
-
import Label from './Label.svelte';
|
|
4
|
-
import Button from './Button.svelte';
|
|
5
|
-
import { getIconSet } from '../../icons/context.js';
|
|
6
|
-
import { defaultIconSet } from '../../icons/sets/default.js';
|
|
7
|
-
|
|
8
|
-
let {
|
|
9
|
-
name,
|
|
10
|
-
id,
|
|
11
|
-
value = $bindable(''),
|
|
12
|
-
autocomplete,
|
|
13
|
-
required = true,
|
|
14
|
-
placeholder = '',
|
|
15
|
-
invalid = false,
|
|
16
|
-
labelClass,
|
|
17
|
-
inputClass,
|
|
18
|
-
buttonClass,
|
|
19
|
-
}: {
|
|
20
|
-
name?: string;
|
|
21
|
-
id?: string;
|
|
22
|
-
value?: string;
|
|
23
|
-
autocomplete?: FullAutoFill;
|
|
24
|
-
required?: boolean;
|
|
25
|
-
placeholder?: string;
|
|
26
|
-
invalid?: boolean;
|
|
27
|
-
labelClass?: string;
|
|
28
|
-
inputClass?: string;
|
|
29
|
-
buttonClass?: string;
|
|
30
|
-
} = $props();
|
|
31
|
-
|
|
32
|
-
const icons = $derived(getIconSet() ?? defaultIconSet);
|
|
33
|
-
let visible = $state(false);
|
|
34
|
-
</script>
|
|
35
|
-
|
|
36
|
-
<Label
|
|
37
|
-
class={[
|
|
38
|
-
'input input-bordered w-full',
|
|
39
|
-
{ 'input-error': invalid },
|
|
40
|
-
labelClass
|
|
41
|
-
]}
|
|
42
|
-
>
|
|
43
|
-
<input
|
|
44
|
-
{name}
|
|
45
|
-
{id}
|
|
46
|
-
{required}
|
|
47
|
-
{placeholder}
|
|
48
|
-
bind:value
|
|
49
|
-
{autocomplete}
|
|
50
|
-
type={visible ? 'text' : 'password'}
|
|
51
|
-
class={['grow', inputClass]}
|
|
52
|
-
/>
|
|
53
|
-
<Button
|
|
54
|
-
btn={false}
|
|
55
|
-
class={['cursor-pointer opacity-60 hover:opacity-100', buttonClass].filter(Boolean).join(' ')}
|
|
56
|
-
aria-label={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
|
57
|
-
aria-pressed={visible}
|
|
58
|
-
onclick={() => (visible = !visible)}
|
|
59
|
-
>
|
|
60
|
-
{#if visible}
|
|
61
|
-
{@const HideIcon = icons.passwordHide}
|
|
62
|
-
<HideIcon class="size-4" />
|
|
63
|
-
{:else}
|
|
64
|
-
{@const ShowIcon = icons.passwordShow}
|
|
65
|
-
<ShowIcon class="size-4" />
|
|
66
|
-
{/if}
|
|
67
|
-
</Button>
|
|
68
|
-
</Label>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { FullAutoFill } from 'svelte/elements';
|
|
3
|
+
import Label from './Label.svelte';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
import { getIconSet } from '../../icons/context.js';
|
|
6
|
+
import { defaultIconSet } from '../../icons/sets/default.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
name,
|
|
10
|
+
id,
|
|
11
|
+
value = $bindable(''),
|
|
12
|
+
autocomplete,
|
|
13
|
+
required = true,
|
|
14
|
+
placeholder = '',
|
|
15
|
+
invalid = false,
|
|
16
|
+
labelClass,
|
|
17
|
+
inputClass,
|
|
18
|
+
buttonClass,
|
|
19
|
+
}: {
|
|
20
|
+
name?: string;
|
|
21
|
+
id?: string;
|
|
22
|
+
value?: string;
|
|
23
|
+
autocomplete?: FullAutoFill;
|
|
24
|
+
required?: boolean;
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
invalid?: boolean;
|
|
27
|
+
labelClass?: string;
|
|
28
|
+
inputClass?: string;
|
|
29
|
+
buttonClass?: string;
|
|
30
|
+
} = $props();
|
|
31
|
+
|
|
32
|
+
const icons = $derived(getIconSet() ?? defaultIconSet);
|
|
33
|
+
let visible = $state(false);
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<Label
|
|
37
|
+
class={[
|
|
38
|
+
'input input-bordered w-full',
|
|
39
|
+
{ 'input-error': invalid },
|
|
40
|
+
labelClass
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
<input
|
|
44
|
+
{name}
|
|
45
|
+
{id}
|
|
46
|
+
{required}
|
|
47
|
+
{placeholder}
|
|
48
|
+
bind:value
|
|
49
|
+
{autocomplete}
|
|
50
|
+
type={visible ? 'text' : 'password'}
|
|
51
|
+
class={['grow', inputClass]}
|
|
52
|
+
/>
|
|
53
|
+
<Button
|
|
54
|
+
btn={false}
|
|
55
|
+
class={['cursor-pointer opacity-60 hover:opacity-100', buttonClass].filter(Boolean).join(' ')}
|
|
56
|
+
aria-label={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
|
57
|
+
aria-pressed={visible}
|
|
58
|
+
onclick={() => (visible = !visible)}
|
|
59
|
+
>
|
|
60
|
+
{#if visible}
|
|
61
|
+
{@const HideIcon = icons.passwordHide}
|
|
62
|
+
<HideIcon class="size-4" />
|
|
63
|
+
{:else}
|
|
64
|
+
{@const ShowIcon = icons.passwordShow}
|
|
65
|
+
<ShowIcon class="size-4" />
|
|
66
|
+
{/if}
|
|
67
|
+
</Button>
|
|
68
|
+
</Label>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<span class="text-error ml-0.5">*</span>
|
|
1
|
+
<span class="text-error ml-0.5">*</span>
|
|
@@ -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>
|