@marianmeres/stuic 2.62.0 → 2.63.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";
|
|
@@ -65,11 +65,8 @@
|
|
|
65
65
|
noScrollLock?: boolean;
|
|
66
66
|
style?: string;
|
|
67
67
|
t?: TranslateFn;
|
|
68
|
-
renderValue?: (
|
|
69
|
-
getOptions: (
|
|
70
|
-
q: string,
|
|
71
|
-
current: Item[]
|
|
72
|
-
) => Promise<{ coll?: ItemCollection<Item>; found: Item[] }>;
|
|
68
|
+
renderValue?: (stringifiedItems: string) => string;
|
|
69
|
+
getOptions: (q: string, current: Item[]) => Promise<{ found: Item[] }>;
|
|
73
70
|
notifications?: NotificationsStack;
|
|
74
71
|
cardinality?: number;
|
|
75
72
|
renderOptionLabel?: (item: Item) => string;
|
|
@@ -186,6 +183,10 @@
|
|
|
186
183
|
let modalDialog: ModalDialog = $state()!;
|
|
187
184
|
let innerValue = $state("");
|
|
188
185
|
let isFetching = $state(false);
|
|
186
|
+
let isUnmounted = false;
|
|
187
|
+
onDestroy(() => {
|
|
188
|
+
isUnmounted = true;
|
|
189
|
+
});
|
|
189
190
|
let cardinality = $derived(_cardinality === -1 ? Infinity : _cardinality);
|
|
190
191
|
let isMultiple = $derived(cardinality > 1);
|
|
191
192
|
let showIcons = $derived(isMultiple ? showIconsCheckbox : showIconsRadio);
|
|
@@ -246,6 +247,11 @@
|
|
|
246
247
|
// reconfigure if the prop ever changes during runtime (most likely will NOT)
|
|
247
248
|
$effect(() => {
|
|
248
249
|
_selectedColl.configure({ cardinality });
|
|
250
|
+
// trim excess selections if cardinality was reduced
|
|
251
|
+
if (_selectedColl.size > cardinality) {
|
|
252
|
+
const trimmed = _selectedColl.items.slice(0, cardinality);
|
|
253
|
+
_selectedColl.clear().addMany(trimmed);
|
|
254
|
+
}
|
|
249
255
|
});
|
|
250
256
|
|
|
251
257
|
// now, create the reactive, subscribed variants
|
|
@@ -259,10 +265,6 @@
|
|
|
259
265
|
);
|
|
260
266
|
}
|
|
261
267
|
|
|
262
|
-
// $inspect("options", options);
|
|
263
|
-
// $inspect("selected", selected);
|
|
264
|
-
// $inspect("lastQuery", lastQuery, innerValue);
|
|
265
|
-
|
|
266
268
|
// hidden input which holds the final value (upon which validation happens)
|
|
267
269
|
let parentHiddenInputEl: HTMLInputElement | undefined = $state();
|
|
268
270
|
|
|
@@ -274,38 +276,21 @@
|
|
|
274
276
|
let isAddNewBtnActive = $state(false);
|
|
275
277
|
let touch = $state(new Date());
|
|
276
278
|
|
|
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
279
|
// suggest options as a typeahead feature
|
|
300
280
|
const debounced = new Debounced(() => innerValue, 150);
|
|
281
|
+
let fetchRequestId = 0;
|
|
301
282
|
watch(
|
|
302
283
|
[() => modalDialog.visibility().visible, () => debounced.current],
|
|
303
284
|
([isVisible, currVal]) => {
|
|
304
285
|
if (!isVisible) return;
|
|
305
286
|
isFetching = true;
|
|
287
|
+
const currentRequest = ++fetchRequestId;
|
|
306
288
|
getOptions(currVal, selected.items)
|
|
307
289
|
.then((res) => {
|
|
308
|
-
|
|
290
|
+
// ignore stale responses
|
|
291
|
+
if (currentRequest !== fetchRequestId) return;
|
|
292
|
+
|
|
293
|
+
const { found } = res;
|
|
309
294
|
|
|
310
295
|
// continue normally, with (server) provided options...
|
|
311
296
|
_optionsColl.clear().addMany(found);
|
|
@@ -329,7 +314,7 @@
|
|
|
329
314
|
// IMPORTANT: focus first selected so it scrolls into view on open
|
|
330
315
|
if (_selectedColl.size) {
|
|
331
316
|
waitForNextRepaint().then(() => {
|
|
332
|
-
_optionsColl.setActive(_selectedColl.items[0]);
|
|
317
|
+
if (!isUnmounted) _optionsColl.setActive(_selectedColl.items[0]);
|
|
333
318
|
});
|
|
334
319
|
}
|
|
335
320
|
}
|
|
@@ -431,6 +416,8 @@
|
|
|
431
416
|
return groupped;
|
|
432
417
|
}
|
|
433
418
|
|
|
419
|
+
let groupedOptions = $derived(_normalize_and_group_options(options.items));
|
|
420
|
+
|
|
434
421
|
const BTN_CLS = [
|
|
435
422
|
"no-focus-visible",
|
|
436
423
|
"text-left rounded-md py-2 px-2.5 flex items-center space-x-2",
|
|
@@ -551,7 +538,7 @@
|
|
|
551
538
|
let extra = '';
|
|
552
539
|
if (vals.length > limit) {
|
|
553
540
|
vals = vals.slice(0, limit);
|
|
554
|
-
extra = `, ... <span class="text-sm opacity-
|
|
541
|
+
extra = `, ... <span class="text-sm opacity-75">(+${(origLength - limit)})</span>`;
|
|
555
542
|
}
|
|
556
543
|
return vals.filter(v => v != null).map(_renderOptionLabel).join(", ") + extra;
|
|
557
544
|
} catch (e) {
|
|
@@ -568,6 +555,7 @@
|
|
|
568
555
|
preEscapeClose={escape}
|
|
569
556
|
classDialog="items-start"
|
|
570
557
|
class="w-full max-w-2xl bg-transparent pointer-events-none"
|
|
558
|
+
ariaLabelledby={id}
|
|
571
559
|
{noScrollLock}
|
|
572
560
|
>
|
|
573
561
|
<div class="pt-0 md:pt-[20vh] w-full">
|
|
@@ -601,7 +589,8 @@
|
|
|
601
589
|
}
|
|
602
590
|
}}
|
|
603
591
|
autocomplete="off"
|
|
604
|
-
|
|
592
|
+
aria-controls={`${id}-options`}
|
|
593
|
+
name={`field-${id}`}
|
|
605
594
|
{...rest}
|
|
606
595
|
/>
|
|
607
596
|
|
|
@@ -641,7 +630,7 @@
|
|
|
641
630
|
|
|
642
631
|
<span class="p-1 m-1 text-sm"> </span>
|
|
643
632
|
<span
|
|
644
|
-
class="flex-1 block justify-end opacity-
|
|
633
|
+
class="flex-1 block justify-end opacity-75 text-right text-xs p-1 pr-2"
|
|
645
634
|
>
|
|
646
635
|
{selected.items.length}
|
|
647
636
|
{#if cardinality > 0 && cardinality < Infinity}
|
|
@@ -653,6 +642,7 @@
|
|
|
653
642
|
|
|
654
643
|
<!-- {#if options.items.length} -->
|
|
655
644
|
<div
|
|
645
|
+
id={`${id}-options`}
|
|
656
646
|
class={[
|
|
657
647
|
"options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
|
|
658
648
|
"h-55 max-h-55",
|
|
@@ -661,7 +651,6 @@
|
|
|
661
651
|
tabindex="-1"
|
|
662
652
|
>
|
|
663
653
|
{#if isFetching && !options.items.length}
|
|
664
|
-
<!-- <div class="p-4 opacity-50"> -->
|
|
665
654
|
<div class="flex opacity-50 text-sm h-full items-center justify-center">
|
|
666
655
|
<Spinner class="w-4" />
|
|
667
656
|
</div>
|
|
@@ -688,7 +677,7 @@
|
|
|
688
677
|
</div>
|
|
689
678
|
{/if}
|
|
690
679
|
|
|
691
|
-
{#each
|
|
680
|
+
{#each groupedOptions as [_optgroup, _opts]}
|
|
692
681
|
{#if _optgroup}
|
|
693
682
|
<div
|
|
694
683
|
class={twMerge(
|
|
@@ -734,7 +723,7 @@
|
|
|
734
723
|
active && classOptionActive
|
|
735
724
|
)}
|
|
736
725
|
tabindex="-1"
|
|
737
|
-
role="checkbox"
|
|
726
|
+
role={isMultiple ? "checkbox" : "radio"}
|
|
738
727
|
aria-checked={isSelected}
|
|
739
728
|
>
|
|
740
729
|
{#if showIcons}
|
|
@@ -765,7 +754,7 @@
|
|
|
765
754
|
</div>
|
|
766
755
|
<!-- {/if} -->
|
|
767
756
|
<div class="p-2 px-3 flex items-end justify-between">
|
|
768
|
-
<div class="text-
|
|
757
|
+
<div class="text-xs opacity-75">
|
|
769
758
|
<!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
|
|
770
759
|
{#if allowUnknown}
|
|
771
760
|
{@html t("unknown_allowed")}
|
|
@@ -811,7 +800,8 @@
|
|
|
811
800
|
onclick={(e) => {
|
|
812
801
|
e.preventDefault();
|
|
813
802
|
if (innerValue.trim() == "") {
|
|
814
|
-
|
|
803
|
+
escape();
|
|
804
|
+
return modalDialog.close();
|
|
815
805
|
}
|
|
816
806
|
innerValue = "";
|
|
817
807
|
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";
|
|
@@ -49,9 +49,8 @@ export interface Props extends Record<string, any> {
|
|
|
49
49
|
noScrollLock?: boolean;
|
|
50
50
|
style?: string;
|
|
51
51
|
t?: TranslateFn;
|
|
52
|
-
renderValue?: (
|
|
52
|
+
renderValue?: (stringifiedItems: string) => string;
|
|
53
53
|
getOptions: (q: string, current: Item[]) => Promise<{
|
|
54
|
-
coll?: ItemCollection<Item>;
|
|
55
54
|
found: Item[];
|
|
56
55
|
}>;
|
|
57
56
|
notifications?: NotificationsStack;
|