@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], [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";
@@ -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?: (strigifiedItems: string) => string;
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
- const { found, coll } = res;
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-50">(+${(origLength - limit)})</span>`;
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
- name={`rand-${Math.random().toString(36).slice(2)}`}
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">&nbsp;</span>
643
638
  <span
644
- class="flex-1 block justify-end opacity-50 text-right text-sm p-1 pr-2"
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 _normalize_and_group_options(options.items) as [_optgroup, _opts]}
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-sm opacity-50">
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
- return escape();
809
+ escape();
810
+ return modalDialog.close();
815
811
  }
816
812
  innerValue = "";
817
813
  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";
@@ -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?: (strigifiedItems: string) => string;
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "2.62.0",
3
+ "version": "2.64.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",