@marianmeres/stuic 2.62.0 → 2.64.0
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.
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
let isFetching = $state(false);
|
|
96
96
|
let optionsBox: HTMLDivElement | undefined = $state();
|
|
97
97
|
let activeEl: HTMLButtonElement | undefined = $state();
|
|
98
|
+
let fetchRequestId = 0;
|
|
98
99
|
|
|
99
100
|
//
|
|
100
101
|
// svelte-ignore state_referenced_locally
|
|
@@ -119,15 +120,18 @@
|
|
|
119
120
|
|
|
120
121
|
//
|
|
121
122
|
const debounced = new Debounced(() => q, 150);
|
|
122
|
-
watch([() => debounced.current], ([currQ], [
|
|
123
|
+
watch([() => debounced.current], ([currQ], [_oldQ]) => {
|
|
123
124
|
if (!currQ && !showAllOnEmptyQ) {
|
|
124
125
|
_optionsColl.clear();
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
isFetching = true;
|
|
130
|
+
const currentRequest = ++fetchRequestId;
|
|
129
131
|
getOptions(`${currQ}`, [])
|
|
130
132
|
.then((res) => {
|
|
133
|
+
// Ignore stale responses
|
|
134
|
+
if (currentRequest !== fetchRequestId) return;
|
|
131
135
|
_optionsColl.clear().addMany(res);
|
|
132
136
|
})
|
|
133
137
|
.catch((e) => {
|
|
@@ -159,6 +163,7 @@
|
|
|
159
163
|
|
|
160
164
|
// internal DRY
|
|
161
165
|
const rand = Math.random().toString(36).slice(2);
|
|
166
|
+
const listId = `cmd-menu-list-${rand}`;
|
|
162
167
|
function btn_id(id: string | number, prefix = "btn-") {
|
|
163
168
|
return prefix + rand + strHash(`${id}`.repeat(3));
|
|
164
169
|
}
|
|
@@ -235,6 +240,9 @@
|
|
|
235
240
|
class="search m-2 shadow-xl"
|
|
236
241
|
classLabelBox="m-0"
|
|
237
242
|
autocomplete="off"
|
|
243
|
+
aria-autocomplete="list"
|
|
244
|
+
aria-controls={options.size ? listId : undefined}
|
|
245
|
+
aria-activedescendant={options.active ? btn_id(options.active[itemIdPropName]) : undefined}
|
|
238
246
|
placeholder={searchPlaceholder ?? t("search_placeholder")}
|
|
239
247
|
classInputBoxWrap={twMerge(
|
|
240
248
|
// always look like focused
|
|
@@ -282,6 +290,11 @@
|
|
|
282
290
|
</div>
|
|
283
291
|
{/snippet}
|
|
284
292
|
{#snippet inputBelow()}
|
|
293
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">
|
|
294
|
+
{#if options.size}
|
|
295
|
+
{options.size} results available
|
|
296
|
+
{/if}
|
|
297
|
+
</div>
|
|
285
298
|
{#if options.size}
|
|
286
299
|
<div
|
|
287
300
|
class={twMerge(
|
|
@@ -292,6 +305,9 @@
|
|
|
292
305
|
)}
|
|
293
306
|
bind:this={optionsBox}
|
|
294
307
|
tabindex="-1"
|
|
308
|
+
role="listbox"
|
|
309
|
+
id={listId}
|
|
310
|
+
aria-label="Search results"
|
|
295
311
|
>
|
|
296
312
|
{#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
|
|
297
313
|
<!-- {console.log(11111, _optgroup, _opts)} -->
|
|
@@ -303,15 +319,17 @@
|
|
|
303
319
|
{_optgroup}
|
|
304
320
|
</div>
|
|
305
321
|
{/if}
|
|
306
|
-
<ul>
|
|
307
|
-
{#each _opts as item (item
|
|
322
|
+
<ul role="presentation">
|
|
323
|
+
{#each _opts as item (item[itemIdPropName])}
|
|
308
324
|
{@const active =
|
|
309
325
|
item[itemIdPropName] === options.active?.[itemIdPropName]}
|
|
310
326
|
<!-- {@const isSelected = false} -->
|
|
311
|
-
<li class:active>
|
|
327
|
+
<li class:active role="presentation">
|
|
312
328
|
<button
|
|
313
329
|
class:active
|
|
314
330
|
type="button"
|
|
331
|
+
role="option"
|
|
332
|
+
aria-selected={active}
|
|
315
333
|
class={twMerge(
|
|
316
334
|
"no-focus-visible",
|
|
317
335
|
"text-left rounded-md py-2 px-2.5",
|
|
@@ -322,7 +340,6 @@
|
|
|
322
340
|
"hover:border-neutral-400 dark:hover:border-neutral-500",
|
|
323
341
|
active && "bg-neutral-200 dark:bg-neutral-800",
|
|
324
342
|
classOption,
|
|
325
|
-
// active && "border-neutral-400",
|
|
326
343
|
active && classOptionActive
|
|
327
344
|
)}
|
|
328
345
|
id={btn_id(item[itemIdPropName])}
|
|
@@ -340,8 +357,6 @@
|
|
|
340
357
|
}
|
|
341
358
|
}}
|
|
342
359
|
>
|
|
343
|
-
<!-- role="checkbox"
|
|
344
|
-
aria-checked={active} -->
|
|
345
360
|
{_renderOptionLabel(item)}
|
|
346
361
|
</button>
|
|
347
362
|
</li>
|
|
@@ -361,4 +376,15 @@
|
|
|
361
376
|
.options {
|
|
362
377
|
scrollbar-width: thin;
|
|
363
378
|
}
|
|
379
|
+
.sr-only {
|
|
380
|
+
position: absolute;
|
|
381
|
+
width: 1px;
|
|
382
|
+
height: 1px;
|
|
383
|
+
padding: 0;
|
|
384
|
+
margin: -1px;
|
|
385
|
+
overflow: hidden;
|
|
386
|
+
clip: rect(0, 0, 0, 0);
|
|
387
|
+
white-space: nowrap;
|
|
388
|
+
border: 0;
|
|
389
|
+
}
|
|
364
390
|
</style>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { iconSearch, iconCheck, iconCircle, iconSquare } from "../../icons/index.js";
|
|
4
4
|
import { ItemCollection, type Item } from "@marianmeres/item-collection";
|
|
5
5
|
import { Debounced, watch } from "runed";
|
|
6
|
-
import { tick, type Snippet } from "svelte";
|
|
6
|
+
import { onDestroy, tick, type Snippet } from "svelte";
|
|
7
7
|
import { tooltip } from "../../actions/index.js";
|
|
8
8
|
import { type ValidateOptions } from "../../actions/validate.svelte.js";
|
|
9
9
|
import type { TranslateFn } from "../../types.js";
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
export interface Props extends Record<string, any> {
|
|
35
35
|
trigger?: Snippet<[{ value: string; modal: ModalDialog }]>;
|
|
36
|
+
modal?: ModalDialog;
|
|
36
37
|
input?: HTMLInputElement;
|
|
37
38
|
value: string;
|
|
38
39
|
label?: SnippetWithId | THC;
|
|
@@ -65,11 +66,8 @@
|
|
|
65
66
|
noScrollLock?: boolean;
|
|
66
67
|
style?: string;
|
|
67
68
|
t?: TranslateFn;
|
|
68
|
-
renderValue?: (
|
|
69
|
-
getOptions: (
|
|
70
|
-
q: string,
|
|
71
|
-
current: Item[]
|
|
72
|
-
) => Promise<{ coll?: ItemCollection<Item>; found: Item[] }>;
|
|
69
|
+
renderValue?: (stringifiedItems: string) => string;
|
|
70
|
+
getOptions: (q: string, current: Item[]) => Promise<{ found: Item[] }>;
|
|
73
71
|
notifications?: NotificationsStack;
|
|
74
72
|
cardinality?: number;
|
|
75
73
|
renderOptionLabel?: (item: Item) => string;
|
|
@@ -126,6 +124,7 @@
|
|
|
126
124
|
|
|
127
125
|
let {
|
|
128
126
|
trigger,
|
|
127
|
+
modal = $bindable(),
|
|
129
128
|
input = $bindable(),
|
|
130
129
|
value = $bindable(), //
|
|
131
130
|
label = "",
|
|
@@ -184,8 +183,16 @@
|
|
|
184
183
|
}: Props = $props();
|
|
185
184
|
|
|
186
185
|
let modalDialog: ModalDialog = $state()!;
|
|
186
|
+
// Sync internal modal state to bindable prop for external access
|
|
187
|
+
$effect(() => {
|
|
188
|
+
modal = modalDialog;
|
|
189
|
+
});
|
|
187
190
|
let innerValue = $state("");
|
|
188
191
|
let isFetching = $state(false);
|
|
192
|
+
let isUnmounted = false;
|
|
193
|
+
onDestroy(() => {
|
|
194
|
+
isUnmounted = true;
|
|
195
|
+
});
|
|
189
196
|
let cardinality = $derived(_cardinality === -1 ? Infinity : _cardinality);
|
|
190
197
|
let isMultiple = $derived(cardinality > 1);
|
|
191
198
|
let showIcons = $derived(isMultiple ? showIconsCheckbox : showIconsRadio);
|
|
@@ -246,6 +253,11 @@
|
|
|
246
253
|
// reconfigure if the prop ever changes during runtime (most likely will NOT)
|
|
247
254
|
$effect(() => {
|
|
248
255
|
_selectedColl.configure({ cardinality });
|
|
256
|
+
// trim excess selections if cardinality was reduced
|
|
257
|
+
if (_selectedColl.size > cardinality) {
|
|
258
|
+
const trimmed = _selectedColl.items.slice(0, cardinality);
|
|
259
|
+
_selectedColl.clear().addMany(trimmed);
|
|
260
|
+
}
|
|
249
261
|
});
|
|
250
262
|
|
|
251
263
|
// now, create the reactive, subscribed variants
|
|
@@ -259,10 +271,6 @@
|
|
|
259
271
|
);
|
|
260
272
|
}
|
|
261
273
|
|
|
262
|
-
// $inspect("options", options);
|
|
263
|
-
// $inspect("selected", selected);
|
|
264
|
-
// $inspect("lastQuery", lastQuery, innerValue);
|
|
265
|
-
|
|
266
274
|
// hidden input which holds the final value (upon which validation happens)
|
|
267
275
|
let parentHiddenInputEl: HTMLInputElement | undefined = $state();
|
|
268
276
|
|
|
@@ -274,38 +282,21 @@
|
|
|
274
282
|
let isAddNewBtnActive = $state(false);
|
|
275
283
|
let touch = $state(new Date());
|
|
276
284
|
|
|
277
|
-
// set value on open
|
|
278
|
-
// watch(
|
|
279
|
-
// () => modalDialog.visibility().visible,
|
|
280
|
-
// (isVisible, wasVisible) => {
|
|
281
|
-
// // modal was just opened
|
|
282
|
-
// if (isVisible) {
|
|
283
|
-
// _selectedColl.clear().addMany(maybeJsonParse(value));
|
|
284
|
-
// console.log(_selectedColl.dump());
|
|
285
|
-
// // IMPORTANT: focus first selected so it scrolls into view on open
|
|
286
|
-
// if (_selectedColl.size) {
|
|
287
|
-
// console.log(1111);
|
|
288
|
-
// waitForNextRepaint().then(() => {
|
|
289
|
-
// _optionsColl.setActive(_selectedColl.items[0]);
|
|
290
|
-
// waitForNextRepaint().then(() => {
|
|
291
|
-
// scrollIntoViewTrigger = new Date();
|
|
292
|
-
// });
|
|
293
|
-
// });
|
|
294
|
-
// }
|
|
295
|
-
// }
|
|
296
|
-
// }
|
|
297
|
-
// );
|
|
298
|
-
|
|
299
285
|
// suggest options as a typeahead feature
|
|
300
286
|
const debounced = new Debounced(() => innerValue, 150);
|
|
287
|
+
let fetchRequestId = 0;
|
|
301
288
|
watch(
|
|
302
289
|
[() => modalDialog.visibility().visible, () => debounced.current],
|
|
303
290
|
([isVisible, currVal]) => {
|
|
304
291
|
if (!isVisible) return;
|
|
305
292
|
isFetching = true;
|
|
293
|
+
const currentRequest = ++fetchRequestId;
|
|
306
294
|
getOptions(currVal, selected.items)
|
|
307
295
|
.then((res) => {
|
|
308
|
-
|
|
296
|
+
// ignore stale responses
|
|
297
|
+
if (currentRequest !== fetchRequestId) return;
|
|
298
|
+
|
|
299
|
+
const { found } = res;
|
|
309
300
|
|
|
310
301
|
// continue normally, with (server) provided options...
|
|
311
302
|
_optionsColl.clear().addMany(found);
|
|
@@ -329,7 +320,7 @@
|
|
|
329
320
|
// IMPORTANT: focus first selected so it scrolls into view on open
|
|
330
321
|
if (_selectedColl.size) {
|
|
331
322
|
waitForNextRepaint().then(() => {
|
|
332
|
-
_optionsColl.setActive(_selectedColl.items[0]);
|
|
323
|
+
if (!isUnmounted) _optionsColl.setActive(_selectedColl.items[0]);
|
|
333
324
|
});
|
|
334
325
|
}
|
|
335
326
|
}
|
|
@@ -431,6 +422,8 @@
|
|
|
431
422
|
return groupped;
|
|
432
423
|
}
|
|
433
424
|
|
|
425
|
+
let groupedOptions = $derived(_normalize_and_group_options(options.items));
|
|
426
|
+
|
|
434
427
|
const BTN_CLS = [
|
|
435
428
|
"no-focus-visible",
|
|
436
429
|
"text-left rounded-md py-2 px-2.5 flex items-center space-x-2",
|
|
@@ -551,7 +544,7 @@
|
|
|
551
544
|
let extra = '';
|
|
552
545
|
if (vals.length > limit) {
|
|
553
546
|
vals = vals.slice(0, limit);
|
|
554
|
-
extra = `, ... <span class="text-sm opacity-
|
|
547
|
+
extra = `, ... <span class="text-sm opacity-75">(+${(origLength - limit)})</span>`;
|
|
555
548
|
}
|
|
556
549
|
return vals.filter(v => v != null).map(_renderOptionLabel).join(", ") + extra;
|
|
557
550
|
} catch (e) {
|
|
@@ -568,6 +561,7 @@
|
|
|
568
561
|
preEscapeClose={escape}
|
|
569
562
|
classDialog="items-start"
|
|
570
563
|
class="w-full max-w-2xl bg-transparent pointer-events-none"
|
|
564
|
+
ariaLabelledby={id}
|
|
571
565
|
{noScrollLock}
|
|
572
566
|
>
|
|
573
567
|
<div class="pt-0 md:pt-[20vh] w-full">
|
|
@@ -601,7 +595,8 @@
|
|
|
601
595
|
}
|
|
602
596
|
}}
|
|
603
597
|
autocomplete="off"
|
|
604
|
-
|
|
598
|
+
aria-controls={`${id}-options`}
|
|
599
|
+
name={`field-${id}`}
|
|
605
600
|
{...rest}
|
|
606
601
|
/>
|
|
607
602
|
|
|
@@ -641,7 +636,7 @@
|
|
|
641
636
|
|
|
642
637
|
<span class="p-1 m-1 text-sm"> </span>
|
|
643
638
|
<span
|
|
644
|
-
class="flex-1 block justify-end opacity-
|
|
639
|
+
class="flex-1 block justify-end opacity-75 text-right text-xs p-1 pr-2"
|
|
645
640
|
>
|
|
646
641
|
{selected.items.length}
|
|
647
642
|
{#if cardinality > 0 && cardinality < Infinity}
|
|
@@ -653,6 +648,7 @@
|
|
|
653
648
|
|
|
654
649
|
<!-- {#if options.items.length} -->
|
|
655
650
|
<div
|
|
651
|
+
id={`${id}-options`}
|
|
656
652
|
class={[
|
|
657
653
|
"options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
|
|
658
654
|
"h-55 max-h-55",
|
|
@@ -661,7 +657,6 @@
|
|
|
661
657
|
tabindex="-1"
|
|
662
658
|
>
|
|
663
659
|
{#if isFetching && !options.items.length}
|
|
664
|
-
<!-- <div class="p-4 opacity-50"> -->
|
|
665
660
|
<div class="flex opacity-50 text-sm h-full items-center justify-center">
|
|
666
661
|
<Spinner class="w-4" />
|
|
667
662
|
</div>
|
|
@@ -688,7 +683,7 @@
|
|
|
688
683
|
</div>
|
|
689
684
|
{/if}
|
|
690
685
|
|
|
691
|
-
{#each
|
|
686
|
+
{#each groupedOptions as [_optgroup, _opts]}
|
|
692
687
|
{#if _optgroup}
|
|
693
688
|
<div
|
|
694
689
|
class={twMerge(
|
|
@@ -734,7 +729,7 @@
|
|
|
734
729
|
active && classOptionActive
|
|
735
730
|
)}
|
|
736
731
|
tabindex="-1"
|
|
737
|
-
role="checkbox"
|
|
732
|
+
role={isMultiple ? "checkbox" : "radio"}
|
|
738
733
|
aria-checked={isSelected}
|
|
739
734
|
>
|
|
740
735
|
{#if showIcons}
|
|
@@ -765,7 +760,7 @@
|
|
|
765
760
|
</div>
|
|
766
761
|
<!-- {/if} -->
|
|
767
762
|
<div class="p-2 px-3 flex items-end justify-between">
|
|
768
|
-
<div class="text-
|
|
763
|
+
<div class="text-xs opacity-75">
|
|
769
764
|
<!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
|
|
770
765
|
{#if allowUnknown}
|
|
771
766
|
{@html t("unknown_allowed")}
|
|
@@ -811,7 +806,8 @@
|
|
|
811
806
|
onclick={(e) => {
|
|
812
807
|
e.preventDefault();
|
|
813
808
|
if (innerValue.trim() == "") {
|
|
814
|
-
|
|
809
|
+
escape();
|
|
810
|
+
return modalDialog.close();
|
|
815
811
|
}
|
|
816
812
|
innerValue = "";
|
|
817
813
|
input?.focus();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Item } from "@marianmeres/item-collection";
|
|
2
2
|
import { type Snippet } from "svelte";
|
|
3
3
|
import { type ValidateOptions } from "../../actions/validate.svelte.js";
|
|
4
4
|
import type { TranslateFn } from "../../types.js";
|
|
@@ -17,6 +17,7 @@ export interface Props extends Record<string, any> {
|
|
|
17
17
|
value: string;
|
|
18
18
|
modal: ModalDialog;
|
|
19
19
|
}]>;
|
|
20
|
+
modal?: ModalDialog;
|
|
20
21
|
input?: HTMLInputElement;
|
|
21
22
|
value: string;
|
|
22
23
|
label?: SnippetWithId | THC;
|
|
@@ -49,9 +50,8 @@ export interface Props extends Record<string, any> {
|
|
|
49
50
|
noScrollLock?: boolean;
|
|
50
51
|
style?: string;
|
|
51
52
|
t?: TranslateFn;
|
|
52
|
-
renderValue?: (
|
|
53
|
+
renderValue?: (stringifiedItems: string) => string;
|
|
53
54
|
getOptions: (q: string, current: Item[]) => Promise<{
|
|
54
|
-
coll?: ItemCollection<Item>;
|
|
55
55
|
found: Item[];
|
|
56
56
|
}>;
|
|
57
57
|
notifications?: NotificationsStack;
|
|
@@ -66,6 +66,6 @@ export interface Props extends Record<string, any> {
|
|
|
66
66
|
itemIdPropName?: string;
|
|
67
67
|
onChange?: (value: string) => void;
|
|
68
68
|
}
|
|
69
|
-
declare const FieldOptions: import("svelte").Component<Props, {}, "value" | "input">;
|
|
69
|
+
declare const FieldOptions: import("svelte").Component<Props, {}, "value" | "input" | "modal">;
|
|
70
70
|
type FieldOptions = ReturnType<typeof FieldOptions>;
|
|
71
71
|
export default FieldOptions;
|