@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], [oldQ]) => {
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.id)}
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?: (strigifiedItems: string) => string;
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
- const { found, coll } = res;
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-50">(+${(origLength - limit)})</span>`;
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
- name={`rand-${Math.random().toString(36).slice(2)}`}
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">&nbsp;</span>
643
632
  <span
644
- class="flex-1 block justify-end opacity-50 text-right text-sm p-1 pr-2"
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 _normalize_and_group_options(options.items) as [_optgroup, _opts]}
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-sm opacity-50">
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
- return escape();
803
+ escape();
804
+ return modalDialog.close();
815
805
  }
816
806
  innerValue = "";
817
807
  input?.focus();
@@ -1,4 +1,4 @@
1
- import { ItemCollection, type Item } from "@marianmeres/item-collection";
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?: (strigifiedItems: string) => string;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.62.0",
3
+ "version": "2.63.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",