@marianmeres/stuic 2.0.0-next.4 → 2.0.2

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.
Files changed (61) hide show
  1. package/dist/actions/file-dropzone.svelte.d.ts +8 -0
  2. package/dist/actions/file-dropzone.svelte.js +43 -0
  3. package/dist/actions/highlight-dragover.svelte.js +16 -3
  4. package/dist/actions/index.d.ts +2 -0
  5. package/dist/actions/index.js +2 -0
  6. package/dist/actions/resizable-width.svelte.d.ts +21 -0
  7. package/dist/actions/resizable-width.svelte.js +162 -0
  8. package/dist/actions/validate.svelte.js +13 -13
  9. package/dist/components/Backdrop/Backdrop.svelte +1 -1
  10. package/dist/components/Button/Button.svelte +2 -2
  11. package/dist/components/Button/Button.svelte.d.ts +1 -1
  12. package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte +170 -0
  13. package/dist/components/ButtonGroupRadio/ButtonGroupRadio.svelte.d.ts +26 -0
  14. package/dist/components/ButtonGroupRadio/index.css +23 -0
  15. package/dist/components/ButtonGroupRadio/index.d.ts +1 -0
  16. package/dist/components/ButtonGroupRadio/index.js +1 -0
  17. package/dist/components/CommandMenu/CommandMenu.svelte +365 -0
  18. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +25 -0
  19. package/dist/components/CommandMenu/index.d.ts +1 -0
  20. package/dist/components/CommandMenu/index.js +1 -0
  21. package/dist/components/Input/FieldInput.svelte +1 -0
  22. package/dist/components/Input/FieldLikeButton.svelte +16 -7
  23. package/dist/components/Input/FieldLikeButton.svelte.d.ts +1 -1
  24. package/dist/components/Input/FieldOptions.svelte +308 -136
  25. package/dist/components/Input/FieldOptions.svelte.d.ts +15 -8
  26. package/dist/components/Input/_internal/InputWrap.svelte +7 -6
  27. package/dist/components/Modal/Modal.svelte +10 -5
  28. package/dist/components/ModalDialog/ModalDialog.svelte +25 -0
  29. package/dist/components/Notifications/Notifications.svelte +1 -1
  30. package/dist/components/Progress/_internal/Bar.svelte +1 -1
  31. package/dist/components/Spinner/SpinnerUnicode.svelte +130 -0
  32. package/dist/components/Spinner/SpinnerUnicode.svelte.d.ts +12 -0
  33. package/dist/components/Spinner/index.d.ts +1 -0
  34. package/dist/components/Spinner/index.js +1 -0
  35. package/dist/components/TypeaheadInput/TypeaheadInput.svelte +261 -0
  36. package/dist/components/TypeaheadInput/TypeaheadInput.svelte.d.ts +40 -0
  37. package/dist/components/TypeaheadInput/index.d.ts +1 -0
  38. package/dist/components/TypeaheadInput/index.js +1 -0
  39. package/dist/index.css +1 -0
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.js +3 -0
  42. package/dist/types.d.ts +1 -0
  43. package/dist/utils/escape-regex.d.ts +1 -0
  44. package/dist/utils/escape-regex.js +1 -0
  45. package/dist/utils/event-emitter.d.ts +18 -0
  46. package/dist/utils/event-emitter.js +40 -0
  47. package/dist/utils/index.d.ts +5 -0
  48. package/dist/utils/index.js +5 -0
  49. package/dist/utils/is-plain-object.d.ts +2 -0
  50. package/dist/utils/is-plain-object.js +4 -0
  51. package/dist/utils/replace-map.d.ts +5 -0
  52. package/dist/utils/replace-map.js +22 -0
  53. package/dist/utils/seconds.d.ts +7 -0
  54. package/dist/utils/seconds.js +35 -0
  55. package/dist/utils/tw-merge.d.ts +2 -0
  56. package/dist/utils/tw-merge.js +4 -0
  57. package/dist/utils/unaccent.d.ts +6 -0
  58. package/dist/utils/unaccent.js +8 -0
  59. package/package.json +70 -66
  60. package/dist/components/ColResize/ColResize.svelte +0 -0
  61. package/dist/components/ColResize/ColResize.svelte.d.ts +0 -26
@@ -1,30 +1,4 @@
1
1
  <script lang="ts" module>
2
- export interface Option {
3
- label: string;
4
- value: any;
5
- }
6
-
7
- // i18n ready
8
- function t_default(k: string) {
9
- const m: Record<string, string> = {
10
- field_req_att: "This field requires attention. Please review and try again.",
11
- cardinality_of: "of",
12
- cardinality_selected: "selected",
13
- submit: "Submit",
14
- select_all: "Select all",
15
- clear_all: "Clear all",
16
- search_placeholder: "Type to search...",
17
- cardinality_full: "Max selection reached",
18
- select_from_list: "Please select from the list",
19
- x_close: "Clear input or close [esc]",
20
- unknown_allowed: "Select from the list or type and submit any value",
21
- unknown_not_allowed: "Select values from the list only",
22
- };
23
- return m[k] ?? k;
24
- }
25
- </script>
26
-
27
- <script lang="ts">
28
2
  import { createClog } from "@marianmeres/clog";
29
3
  import { iconBsSearch } from "@marianmeres/icons-fns/bootstrap/iconBsSearch.js";
30
4
  import { iconLucideCheck } from "@marianmeres/icons-fns/lucide/iconLucideCheck.js";
@@ -32,7 +6,7 @@
32
6
  import { iconLucideSquare } from "@marianmeres/icons-fns/lucide/iconLucideSquare.js";
33
7
  import { ItemCollection, type Item } from "@marianmeres/item-collection";
34
8
  import { Debounced, watch } from "runed";
35
- import { type Snippet } from "svelte";
9
+ import { tick, type Snippet } from "svelte";
36
10
  import { tooltip } from "../../actions/index.js";
37
11
  import { type ValidateOptions } from "../../actions/validate.svelte.js";
38
12
  import { getId } from "../../utils/get-id.js";
@@ -49,7 +23,48 @@
49
23
  import X from "../X/X.svelte";
50
24
  import InputWrap from "./_internal/InputWrap.svelte";
51
25
  import FieldLikeButton from "./FieldLikeButton.svelte";
26
+ import { replaceMap } from "../../utils/replace-map.js";
27
+ import { isPlainObject } from "../../utils/is-plain-object.js";
28
+ import type { TranslateFn } from "../../types.js";
52
29
 
30
+ export interface Option {
31
+ label: string;
32
+ value: any;
33
+ }
34
+
35
+ // i18n ready
36
+ function t_default(
37
+ k: string,
38
+ values: false | null | undefined | Record<string, string | number> = null,
39
+ fallback: string | boolean = "",
40
+ i18nSpanWrap: boolean = true
41
+ ) {
42
+ const m: Record<string, string> = {
43
+ field_req_att: "This field requires attention. Please review and try again.",
44
+ cardinality_of: "of max",
45
+ cardinality_selected: "selected",
46
+ submit: "Submit",
47
+ select_all: "Select results",
48
+ clear_all: "Clear selected",
49
+ clear: "Clear",
50
+ search_placeholder: "Type to search...",
51
+ search_submit_placeholder: "Type to search and/or submit...",
52
+ cardinality_full: "Max selection reached",
53
+ select_from_list: "Please select from the list only",
54
+ x_close: "Clear input or close [esc]",
55
+ unknown_allowed: "Select or type and submit",
56
+ unknown_not_allowed: "Select from the list",
57
+ no_results: "No results found.",
58
+ add_new: 'Add "{{value}}"...',
59
+ click_add_new: "You must add the value to continue",
60
+ };
61
+ let out = m[k] ?? fallback ?? k;
62
+
63
+ return isPlainObject(values) ? replaceMap(out, values as any) : out;
64
+ }
65
+ </script>
66
+
67
+ <script lang="ts">
53
68
  const clog = createClog("FieldOptions");
54
69
 
55
70
  const iconCheckboxEmpty = iconLucideSquare;
@@ -94,23 +109,29 @@
94
109
  //
95
110
  classOption?: string;
96
111
  classOptionActive?: string;
112
+ classOptgroup?: string;
97
113
  //
98
114
  classModalField?: string;
99
115
  noScrollLock?: boolean;
100
116
  //
101
117
  style?: string;
102
- t?: (key: string) => string;
118
+ t?: TranslateFn;
103
119
  //
104
120
  renderValue?: (strigifiedItems: string) => string;
105
- getOptions: (s: string, current: Item[]) => Promise<Item[]>;
121
+ getOptions: (
122
+ q: string,
123
+ current: Item[]
124
+ ) => Promise<{ coll?: ItemCollection<Item>; found: Item[] }>;
106
125
  notifications?: NotificationsStack;
107
126
  // -1 no limit
108
127
  // +n max selected limit
109
128
  cardinality?: number;
110
129
  renderOptionLabel?: (item: Item) => string;
130
+ renderOptionGroup?: (s: string) => string;
111
131
  // whether to allow adding unknown options
112
132
  allowUnknown?: boolean;
113
- showIcons?: boolean;
133
+ showIconsCheckbox?: boolean;
134
+ showIconsRadio?: boolean;
114
135
  searchPlaceholder?: string;
115
136
  name: string;
116
137
  itemIdPropName?: string;
@@ -150,6 +171,7 @@
150
171
  //
151
172
  classOption,
152
173
  classOptionActive,
174
+ classOptgroup,
153
175
  //
154
176
  style,
155
177
  //
@@ -162,8 +184,10 @@
162
184
  notifications,
163
185
  cardinality: _cardinality = Infinity,
164
186
  renderOptionLabel,
187
+ renderOptionGroup = (s: string) => `${s}`.replaceAll("_", " "),
165
188
  allowUnknown = false,
166
- showIcons = true,
189
+ showIconsCheckbox = true,
190
+ showIconsRadio = false,
167
191
  searchPlaceholder,
168
192
  name,
169
193
  itemIdPropName = "id",
@@ -175,6 +199,7 @@
175
199
  let isFetching = $state(false);
176
200
  let cardinality = $derived(_cardinality === -1 ? Infinity : _cardinality);
177
201
  let isMultiple = $derived(cardinality > 1);
202
+ let showIcons = $derived(isMultiple ? showIconsCheckbox : showIconsRadio);
178
203
 
179
204
  //
180
205
  let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
@@ -206,7 +231,8 @@
206
231
  }
207
232
 
208
233
  function sortFn(a: Item, b: Item) {
209
- return _renderOptionLabel(a).localeCompare(_renderOptionLabel(b), undefined, {
234
+ const withOptGroup = (i: Item) => `${i.optgroup || ""}__${_renderOptionLabel(i)}`;
235
+ return withOptGroup(a).localeCompare(withOptGroup(b), undefined, {
210
236
  sensitivity: "base",
211
237
  });
212
238
  }
@@ -222,21 +248,43 @@
222
248
 
223
249
  // second, the selected ones
224
250
  const _selectedColl = new ItemCollection([], {
251
+ // svelte-ignore state_referenced_locally
225
252
  cardinality,
226
253
  sortFn,
227
254
  idPropName: itemIdPropName,
228
255
  });
229
256
 
257
+ // reconfigure if the prop ever changes during runtime (most likely will NOT)
258
+ $effect(() => {
259
+ _selectedColl.configure({ cardinality });
260
+ });
261
+
230
262
  // now, create the reactive, subscribed variants
231
263
  let options = $derived($_optionsColl);
232
264
  let selected = $derived($_selectedColl);
265
+
266
+ // we need to know whether to show "Add xyz"...
267
+ function have_option_label_like(items: Item[], s: string) {
268
+ return items.some(
269
+ (item) => _renderOptionLabel(item).toLowerCase() === `${s}`.toLowerCase()
270
+ );
271
+ }
272
+
233
273
  // $inspect("options", options);
234
274
  // $inspect("selected", selected);
275
+ // $inspect("lastQuery", lastQuery, innerValue);
276
+
277
+ // hidden input which holds the final value (upon which validation happens)
278
+ let parentHiddenInputEl: HTMLInputElement | undefined = $state();
235
279
 
236
280
  let activeEl: HTMLButtonElement | undefined = $state();
237
- let optionsBox: HTMLUListElement | undefined = $state();
281
+ let optionsBox: HTMLDivElement | undefined = $state();
238
282
  let modalEl: HTMLDivElement | undefined = $state();
239
283
 
284
+ // add_new dance...
285
+ let addNewBtn: HTMLButtonElement | undefined = $state();
286
+ let isAddNewBtnActive = $state(false);
287
+
240
288
  // set value on open
241
289
  watch(
242
290
  () => modal.visibility().visible,
@@ -257,7 +305,7 @@
257
305
  // scroll the active option into view
258
306
  $effect(() => {
259
307
  if (modal.visibility().visible && options.active?.[itemIdPropName]) {
260
- activeEl = qsa(`#${btnId(options.active[itemIdPropName])}`, optionsBox)[0] as any;
308
+ activeEl = qsa(`#${btn_id(options.active[itemIdPropName])}`, optionsBox)[0] as any;
261
309
  activeEl?.scrollIntoView({ behavior: "smooth", block: "center" });
262
310
  activeEl?.focus();
263
311
  } else {
@@ -274,10 +322,12 @@
274
322
  isFetching = true;
275
323
  getOptions(currVal, selected.items)
276
324
  .then((res) => {
325
+ const { found, coll } = res;
326
+
277
327
  // always update the existing with recent server data
278
- _selectedColl.patchMany(res);
328
+ _selectedColl.patchMany(found);
279
329
  // continue normally, with (server) provided options...
280
- _optionsColl.clear().addMany(res);
330
+ _optionsColl.clear().addMany(found);
281
331
  })
282
332
  .catch((e) => {
283
333
  console.error(e);
@@ -288,17 +338,64 @@
288
338
  );
289
339
 
290
340
  // internal DRY
291
- function btnId(id: string | number, prefix = "btn-") {
341
+ function btn_id(id: string | number, prefix = "btn-") {
292
342
  return prefix + strHash(`${id}`.repeat(3));
293
343
  }
294
344
 
295
- // this will set the outer bound value (always string) and close modal... further process is left on the consumer
345
+ // "inner" submit
346
+ function try_submit(force = false) {
347
+ clog("try_submit", innerValue);
348
+ if (innerValue) {
349
+ let found = have_option_label_like(_optionsColl.items, innerValue);
350
+ if (!found && !allowUnknown) {
351
+ return notifications?.error(t("select_from_list"), { ttl: 1000 });
352
+ }
353
+
354
+ if (!found && !_optionsColl.size) {
355
+ return notifications?.error(t("click_add_new", { value: innerValue }), {
356
+ ttl: 1000,
357
+ });
358
+ }
359
+
360
+ // maybe submit
361
+ if (_selectedColl.isFull || force) submit();
362
+ }
363
+ // enter on empty input always submits
364
+ else {
365
+ submit();
366
+ }
367
+ }
368
+
369
+ function add_new() {
370
+ // should be noop if called multiple times with same value
371
+ if (allowUnknown && innerValue) {
372
+ const item = { [itemIdPropName]: innerValue };
373
+ if (!isMultiple) _selectedColl.clear();
374
+ // actual selection addon
375
+ _selectedColl.add(item);
376
+ // we might have added a new one, so add it to options as well
377
+ // (will be noop if already exists)...
378
+ _optionsColl.add(item);
379
+ _optionsColl.setActive(item);
380
+ }
381
+ }
382
+
383
+ function _dispatch_change_to_owner() {
384
+ // trigger validation on the parent on each submit (emulate typical browser behaviour)
385
+ tick().then(() => {
386
+ parentHiddenInputEl?.dispatchEvent(new Event("change", { bubbles: true }));
387
+ });
388
+ }
389
+
390
+ // "outer" submit - will set the outer bound value (always string) and close modal...
391
+ // further process is left on the consumer
296
392
  function submit() {
297
393
  // clog("modal submit", $state.snapshot(selected.items));
298
394
  value = JSON.stringify(selected.items);
299
395
  innerValue = "";
300
396
  _optionsColl.clear();
301
397
  modal.close();
398
+ _dispatch_change_to_owner();
302
399
  }
303
400
 
304
401
  // clears, closes, submits nothing
@@ -306,6 +403,64 @@
306
403
  innerValue = "";
307
404
  _optionsColl.clear();
308
405
  modal?.close();
406
+ _dispatch_change_to_owner();
407
+ }
408
+
409
+ function _normalize_and_group_options(opts: Item[]): Map<string, Item[]> {
410
+ const groupped = new Map<string, Item[]>();
411
+ opts.forEach((o) => {
412
+ const optgLabel = renderOptionGroup(o.optgroup || "");
413
+ if (!groupped.has(optgLabel)) groupped.set(optgLabel, []);
414
+ const optgroup = groupped.get(optgLabel);
415
+ optgroup!.push(o);
416
+ });
417
+ return groupped;
418
+ }
419
+
420
+ const BTN_CLS = [
421
+ "no-focus-visible",
422
+ "text-left rounded-md py-2 px-2.5 flex items-center space-x-2",
423
+ "w-full",
424
+ "border border-transparent",
425
+ "focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
426
+ "focus-visible:outline-0 focus-visible:ring-0",
427
+ "hover:border-neutral-400 dark:hover:border-neutral-500",
428
+ ];
429
+
430
+ // add new dance
431
+ $effect(() => {
432
+ if (addNewBtn && isAddNewBtnActive) {
433
+ addNewBtn?.focus();
434
+ _optionsColl.unsetActive(); // make sure to reset
435
+ }
436
+ if (!addNewBtn) {
437
+ isAddNewBtnActive = false;
438
+ }
439
+ });
440
+
441
+ function maybe_activate_add_new(isDown: boolean, isMeta: boolean) {
442
+ // no button, no activation
443
+ if (!addNewBtn) return false;
444
+ const isUp = !isDown;
445
+
446
+ // separating below into distinct ifs, so it's easily readable
447
+
448
+ // if first arrow down
449
+ if (!isAddNewBtnActive && isDown && _optionsColl.activeIndex === undefined) {
450
+ return true;
451
+ }
452
+
453
+ // isActive and isUp (this is a noop, but we must break)
454
+ if (isAddNewBtnActive && isUp) {
455
+ return true;
456
+ }
457
+
458
+ // isUp from first, or is metaUp
459
+ if (!isAddNewBtnActive && isUp && (_optionsColl.activeIndex === 0 || isMeta)) {
460
+ return true;
461
+ }
462
+
463
+ return false;
309
464
  }
310
465
  </script>
311
466
 
@@ -317,10 +472,14 @@
317
472
  if (["ArrowDown", "ArrowUp"].includes(e.key)) {
318
473
  e.preventDefault();
319
474
 
320
- if (e.key === "ArrowUp") {
321
- e.metaKey ? _optionsColl.setActiveFirst() : _optionsColl.setActivePrevious();
322
- } else if (e.key === "ArrowDown") {
323
- e.metaKey ? _optionsColl.setActiveLast() : _optionsColl.setActiveNext();
475
+ isAddNewBtnActive = maybe_activate_add_new(e.key === "ArrowDown", e.metaKey);
476
+
477
+ if (!isAddNewBtnActive) {
478
+ if (e.key === "ArrowUp") {
479
+ e.metaKey ? _optionsColl.setActiveFirst() : _optionsColl.setActivePrevious();
480
+ } else if (e.key === "ArrowDown") {
481
+ e.metaKey ? _optionsColl.setActiveLast() : _optionsColl.setActiveNext();
482
+ }
324
483
  }
325
484
 
326
485
  // common UI convention: radios are selected by arrows
@@ -340,6 +499,7 @@
340
499
  <div>
341
500
  <FieldLikeButton
342
501
  bind:value
502
+ bind:input={parentHiddenInputEl}
343
503
  {name}
344
504
  class={classProp}
345
505
  {label}
@@ -395,7 +555,7 @@
395
555
  >
396
556
  <InputWrap
397
557
  size={renderSize}
398
- class={twMerge("m-4 mb-12 shadow-xl", classModalField)}
558
+ class={twMerge("m-2 mb-12 shadow-xl", classModalField)}
399
559
  classInputBoxWrap={twMerge(
400
560
  // always look like focused
401
561
  `border border-input-accent dark:border-input-accent-dark`,
@@ -413,40 +573,12 @@
413
573
  tabindex={1}
414
574
  {required}
415
575
  {disabled}
416
- placeholder={searchPlaceholder ?? t("search_placeholder")}
576
+ placeholder={searchPlaceholder ??
577
+ t(allowUnknown ? "search_submit_placeholder" : "search_placeholder")}
417
578
  onkeydown={(e) => {
418
579
  if (e.key === "Enter") {
419
580
  e.preventDefault();
420
-
421
- if (innerValue) {
422
- // doing label search, taking first result
423
- let found = _optionsColl.search(innerValue)?.[0];
424
- if (!found) {
425
- if (!allowUnknown) {
426
- return notifications?.error(t("select_from_list"), { ttl: 1000 });
427
- }
428
- found = { [itemIdPropName]: innerValue };
429
- }
430
-
431
- if (!isMultiple) _selectedColl.clear();
432
-
433
- // actual selection addon
434
- _selectedColl.add(found);
435
-
436
- // we might have added a new one, so add it to options as well
437
- // (will be noop if already exists)...
438
- if (allowUnknown) {
439
- _optionsColl.add(found);
440
- _optionsColl.setActive(found);
441
- }
442
-
443
- // maybe submit
444
- if (_selectedColl.isFull) submit();
445
- }
446
- // enter on empty input always submits
447
- else {
448
- submit();
449
- }
581
+ try_submit();
450
582
  }
451
583
  }}
452
584
  autocomplete="off"
@@ -466,6 +598,7 @@
466
598
  "hover:opacity-100 focus-visible:outline-neutral-400 focus-visible:opacity-100"
467
599
  )}
468
600
  tabindex={4}
601
+ disabled={!options.size}
469
602
  >
470
603
  {@html t("select_all")}
471
604
  </button>
@@ -484,7 +617,7 @@
484
617
  tabindex={5}
485
618
  disabled={!selected.items.length}
486
619
  >
487
- {@html t("clear_all")}
620
+ {@html t(cardinality === 1 ? "clear" : "clear_all")}
488
621
  </button>
489
622
 
490
623
  <span class="p-1 m-1 text-xs">&nbsp;</span>
@@ -498,80 +631,119 @@
498
631
  </div>
499
632
 
500
633
  <!-- {#if options.items.length} -->
501
- <ul
502
- class={twMerge(
503
- "options block h-[250px] max-h-[250px] overflow-y-auto overflow-x-hidden space-y-1"
504
- )}
634
+ <div
635
+ class={[
636
+ "options overflow-y-auto overflow-x-hidden space-y-1",
637
+ "h-[220px] max-h-[220px]",
638
+ ]}
505
639
  bind:this={optionsBox}
506
640
  tabindex="-1"
507
641
  >
508
642
  {#if isFetching && !options.items.length}
509
- <div class="p-4 opacity-50">
643
+ <!-- <div class="p-4 opacity-50"> -->
644
+ <div class="flex opacity-50 text-sm h-full items-center justify-center">
510
645
  <Spinner class="w-4" />
511
646
  </div>
647
+ {:else if !options.items.length && !allowUnknown}
648
+ <div class="flex opacity-50 text-sm h-full items-center justify-center">
649
+ {@html t("no_results")}
650
+ </div>
512
651
  {/if}
513
- {#each options.items as item}
514
- {@const active = item[itemIdPropName] === options.active?.[itemIdPropName]}
515
- {@const isSelected =
516
- selected.items && _selectedColl.exists(item[itemIdPropName])}
517
- <li class:active class="px-2">
652
+
653
+ {#if !isFetching && allowUnknown && innerValue && !have_option_label_like(options.items, innerValue)}
654
+ <div class="px-1">
518
655
  <button
519
656
  type="button"
520
- id={btnId(item[itemIdPropName])}
521
- onclick={() => {
522
- if (isMultiple) {
523
- if (selected.isFull && !_selectedColl.exists(item)) {
524
- return notifications?.error(t("cardinality_full"), {
525
- ttl: 1000,
526
- });
527
- }
528
- _selectedColl.toggleAdd(item);
529
- } else {
530
- _selectedColl.clear();
531
- _selectedColl.add(item);
532
- submit();
533
- }
534
- }}
535
- class:active
536
- class:selected={isSelected}
657
+ bind:this={addNewBtn}
658
+ onclick={add_new}
537
659
  class={twMerge(
538
- "no-focus-visible",
539
- "w-full text-left rounded-md py-2 px-2.5 flex items-center space-x-2",
540
- "text-ellipsis border border-transparent",
541
- "focus:outline-0 focus:border-neutral-400 dark:focus:border-neutral-500",
542
- "focus-visible:outline-0 focus-visible:ring-0",
543
- "hover:border-neutral-400 dark:hover:border-neutral-500",
544
- isSelected && "bg-neutral-200 dark:bg-neutral-800",
660
+ BTN_CLS,
545
661
  classOption,
546
- // active && "border-neutral-400",
547
- active && classOptionActive
662
+ isAddNewBtnActive && classOptionActive
548
663
  )}
549
- tabindex="-1"
550
- role="checkbox"
551
- aria-checked={isSelected}
552
664
  >
553
- {#if showIcons}
554
- <span class={isSelected ? "opacity-100" : "opacity-25"}>
555
- {#if isMultiple}
556
- {#if isSelected}
557
- {@html iconCheckboxCheck()}
558
- {:else}
559
- {@html iconCheckboxEmpty()}
560
- {/if}
561
- {:else if isSelected}
562
- {@html iconRadioCheck()}
563
- {:else}
564
- {@html iconRadioEmpty()}
565
- {/if}
566
- </span>
567
- {/if}
568
- <span>{_renderOptionLabel(item)}</span>
665
+ {t("add_new", { value: innerValue })}
569
666
  </button>
570
- </li>
667
+ </div>
668
+ {/if}
669
+
670
+ {#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
671
+ {#if _optgroup}
672
+ <div
673
+ class={twMerge(
674
+ "text-xs capitalize opacity-50 border-b border-black/10 mb-0.5 p-1 mx-1",
675
+ classOptgroup
676
+ )}
677
+ >
678
+ {_optgroup}
679
+ </div>
680
+ {/if}
681
+ <ul class="space-y-0.5">
682
+ <!-- {#each options.items as item} -->
683
+ {#each _opts as item (item[itemIdPropName])}
684
+ {@const active =
685
+ item[itemIdPropName] === options.active?.[itemIdPropName]}
686
+ {@const isSelected =
687
+ selected.items && _selectedColl.exists(item[itemIdPropName])}
688
+ <li class:active class="px-1">
689
+ <button
690
+ type="button"
691
+ id={btn_id(item[itemIdPropName])}
692
+ onclick={() => {
693
+ if (isMultiple) {
694
+ if (selected.isFull && !_selectedColl.exists(item)) {
695
+ return notifications?.error(t("cardinality_full"), {
696
+ ttl: 1000,
697
+ });
698
+ }
699
+ _selectedColl.toggleAdd(item);
700
+ } else {
701
+ _selectedColl.clear();
702
+ _selectedColl.add(item);
703
+ submit();
704
+ }
705
+ }}
706
+ class:active
707
+ class:selected={isSelected}
708
+ class={twMerge(
709
+ BTN_CLS,
710
+ isSelected && "bg-neutral-200 dark:bg-neutral-800",
711
+ classOption,
712
+ // active && "border-neutral-400",
713
+ active && classOptionActive
714
+ )}
715
+ tabindex="-1"
716
+ role="checkbox"
717
+ aria-checked={isSelected}
718
+ >
719
+ {#if showIcons}
720
+ <span class={isSelected ? "opacity-100" : "opacity-25"}>
721
+ {#if isMultiple}
722
+ {#if isSelected}
723
+ {@html iconCheckboxCheck()}
724
+ {:else}
725
+ {@html iconCheckboxEmpty()}
726
+ {/if}
727
+ {:else if isSelected}
728
+ {@html iconRadioCheck()}
729
+ {:else}
730
+ {@html iconRadioEmpty()}
731
+ {/if}
732
+ </span>
733
+ {/if}
734
+ <span
735
+ class={twMerge(
736
+ "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
737
+ )}>{_renderOptionLabel(item)}</span
738
+ >
739
+ </button>
740
+ </li>
741
+ {/each}
742
+ </ul>
571
743
  {/each}
572
- </ul>
744
+ </div>
573
745
  <!-- {/if} -->
574
- <div class="p-2 flex items-end justify-between">
746
+ <div class="p-2 px-3 flex items-end justify-between">
575
747
  <div class="text-xs opacity-50">
576
748
  <!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
577
749
  {#if allowUnknown}
@@ -585,9 +757,9 @@
585
757
  class="control"
586
758
  type="button"
587
759
  variant="primary"
588
- onclick={(e) => {
760
+ onclick={async (e) => {
589
761
  e.preventDefault();
590
- submit();
762
+ try_submit(true);
591
763
  }}
592
764
  tabindex={3}
593
765
  >
@@ -611,7 +783,7 @@
611
783
  "opacity-50 rounded",
612
784
  "hover:opacity-100 hover:bg-neutral-200 dark:hover:bg-neutral-800",
613
785
  "focus-visible:opacity-100 focus-visible:outline-0",
614
- " focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
786
+ "focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
615
787
  )}
616
788
  use:tooltip
617
789
  aria-label={t("x_close")}
@@ -1,12 +1,13 @@
1
- export interface Option {
2
- label: string;
3
- value: any;
4
- }
5
- import { type Item } from "@marianmeres/item-collection";
1
+ import { ItemCollection, type Item } from "@marianmeres/item-collection";
6
2
  import { type Snippet } from "svelte";
7
3
  import { type ValidateOptions } from "../../actions/validate.svelte.js";
8
4
  import { NotificationsStack } from "../Notifications/index.js";
9
5
  import type { THC } from "../Thc/Thc.svelte";
6
+ import type { TranslateFn } from "../../types.js";
7
+ export interface Option {
8
+ label: string;
9
+ value: any;
10
+ }
10
11
  type SnippetWithId = Snippet<[{
11
12
  id: string;
12
13
  }]>;
@@ -38,17 +39,23 @@ interface Props extends Record<string, any> {
38
39
  classBelowBox?: string;
39
40
  classOption?: string;
40
41
  classOptionActive?: string;
42
+ classOptgroup?: string;
41
43
  classModalField?: string;
42
44
  noScrollLock?: boolean;
43
45
  style?: string;
44
- t?: (key: string) => string;
46
+ t?: TranslateFn;
45
47
  renderValue?: (strigifiedItems: string) => string;
46
- getOptions: (s: string, current: Item[]) => Promise<Item[]>;
48
+ getOptions: (q: string, current: Item[]) => Promise<{
49
+ coll?: ItemCollection<Item>;
50
+ found: Item[];
51
+ }>;
47
52
  notifications?: NotificationsStack;
48
53
  cardinality?: number;
49
54
  renderOptionLabel?: (item: Item) => string;
55
+ renderOptionGroup?: (s: string) => string;
50
56
  allowUnknown?: boolean;
51
- showIcons?: boolean;
57
+ showIconsCheckbox?: boolean;
58
+ showIconsRadio?: boolean;
52
59
  searchPlaceholder?: string;
53
60
  name: string;
54
61
  itemIdPropName?: string;