@marianmeres/stuic 2.60.0 → 2.62.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.
@@ -16,7 +16,7 @@
16
16
  import { strHash } from "../../utils/str-hash.js";
17
17
  import { twMerge } from "../../utils/tw-merge.js";
18
18
  import Button from "../Button/Button.svelte";
19
- import Modal from "../Modal/Modal.svelte";
19
+ import { ModalDialog } from "../ModalDialog/index.js";
20
20
  import { NotificationsStack } from "../Notifications/index.js";
21
21
  import Spinner from "../Spinner/Spinner.svelte";
22
22
  import type { THC } from "../Thc/Thc.svelte";
@@ -32,7 +32,7 @@
32
32
  type SnippetWithId = Snippet<[{ id: string }]>;
33
33
 
34
34
  export interface Props extends Record<string, any> {
35
- trigger?: Snippet<[{ value: string; modal: Modal }]>;
35
+ trigger?: Snippet<[{ value: string; modal: ModalDialog }]>;
36
36
  input?: HTMLInputElement;
37
37
  value: string;
38
38
  label?: SnippetWithId | THC;
@@ -134,7 +134,7 @@
134
134
  tabindex = 0,
135
135
  description,
136
136
  class: classProp,
137
- renderSize = "md",
137
+ renderSize = "lg",
138
138
  useTrim = true,
139
139
  //
140
140
  required = false,
@@ -183,7 +183,7 @@
183
183
  ...rest
184
184
  }: Props = $props();
185
185
 
186
- let modal: Modal = $state()!;
186
+ let modalDialog: ModalDialog = $state()!;
187
187
  let innerValue = $state("");
188
188
  let isFetching = $state(false);
189
189
  let cardinality = $derived(_cardinality === -1 ? Infinity : _cardinality);
@@ -268,7 +268,6 @@
268
268
 
269
269
  let activeEl: HTMLButtonElement | undefined = $state();
270
270
  let optionsBox: HTMLDivElement | undefined = $state();
271
- let modalEl: HTMLDivElement | undefined = $state();
272
271
 
273
272
  // add_new dance...
274
273
  let addNewBtn: HTMLButtonElement | undefined = $state();
@@ -277,7 +276,7 @@
277
276
 
278
277
  // set value on open
279
278
  // watch(
280
- // () => modal.visibility().visible,
279
+ // () => modalDialog.visibility().visible,
281
280
  // (isVisible, wasVisible) => {
282
281
  // // modal was just opened
283
282
  // if (isVisible) {
@@ -300,7 +299,7 @@
300
299
  // suggest options as a typeahead feature
301
300
  const debounced = new Debounced(() => innerValue, 150);
302
301
  watch(
303
- [() => modal.visibility().visible, () => debounced.current],
302
+ [() => modalDialog.visibility().visible, () => debounced.current],
304
303
  ([isVisible, currVal]) => {
305
304
  if (!isVisible) return;
306
305
  isFetching = true;
@@ -325,7 +324,7 @@
325
324
  );
326
325
 
327
326
  $effect(() => {
328
- if (modal.visibility().visible && touch) {
327
+ if (modalDialog.visibility().visible && touch) {
329
328
  _selectedColl.clear().addMany(maybeJsonParse(value) as Item[]);
330
329
  // IMPORTANT: focus first selected so it scrolls into view on open
331
330
  if (_selectedColl.size) {
@@ -409,16 +408,15 @@
409
408
  value = JSON.stringify(selected.items);
410
409
  innerValue = "";
411
410
  _optionsColl.clear();
412
- modal.close();
411
+ modalDialog.close();
413
412
  _dispatch_change_to_owner();
414
413
  onChange?.(value);
415
414
  }
416
415
 
417
- // clears, closes, submits nothing
416
+ // clears state and dispatches change; close is handled by ModalDialog's preEscapeClose
418
417
  function escape() {
419
418
  innerValue = "";
420
419
  _optionsColl.clear();
421
- modal?.close();
422
420
  _dispatch_change_to_owner();
423
421
  }
424
422
 
@@ -483,7 +481,7 @@
483
481
  <!-- this must be on window as we're catching any typing anywhere -->
484
482
  <svelte:window
485
483
  onkeydown={(e) => {
486
- if (modal.visibility().visible) {
484
+ if (modalDialog.visibility().visible) {
487
485
  // arrow navigation
488
486
  if (["ArrowDown", "ArrowUp"].includes(e.key)) {
489
487
  e.preventDefault();
@@ -514,7 +512,7 @@
514
512
  <!-- must wrap both -->
515
513
  <div>
516
514
  {#if trigger}
517
- {@render trigger({ value, modal })}
515
+ {@render trigger({ value, modal: modalDialog })}
518
516
  {:else}
519
517
  <FieldLikeButton
520
518
  bind:value
@@ -561,274 +559,279 @@
561
559
  return `${e}`; // either invalid json or not array...
562
560
  }
563
561
  }}
564
- onclick={modal?.open}
562
+ onclick={modalDialog?.open}
565
563
  />
566
564
  {/if}
567
565
 
568
- <Modal
569
- bind:this={modal}
570
- onEscape={escape}
571
- class="bg-transparent dark:bg-transparent"
572
- classInner="max-w-2xl"
573
- bind:el={modalEl}
566
+ <ModalDialog
567
+ bind:this={modalDialog}
568
+ preEscapeClose={escape}
569
+ classDialog="items-start"
570
+ class="w-full max-w-2xl bg-transparent pointer-events-none"
574
571
  {noScrollLock}
575
572
  >
576
- <InputWrap
577
- size={renderSize}
578
- class={twMerge("m-2 mb-12 shadow-xl", classModalField)}
579
- classInputBoxWrap={twMerge(
580
- // always look like focused
581
- `border border-input-accent dark:border-input-accent-dark`,
582
- `ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
583
- )}
584
- {id}
585
- {required}
586
- >
587
- <input
588
- bind:value={innerValue}
589
- bind:this={input}
590
- {type}
591
- {id}
592
- class={twMerge("form-input", renderSize, classInput)}
593
- tabindex={1}
594
- {required}
595
- {disabled}
596
- placeholder={searchPlaceholder ??
597
- t(allowUnknown ? "search_submit_placeholder" : "search_placeholder")}
598
- onkeydown={(e) => {
599
- if (e.key === "Enter") {
600
- e.preventDefault();
601
- try_submit();
602
- }
603
- }}
604
- autocomplete="off"
605
- name={`rand-${Math.random().toString(36).slice(2)}`}
606
- {...rest}
607
- />
608
-
609
- {#snippet inputBelow()}
610
- <div class="h-full border-t p-2 border-black/20">
611
- <div class="text-sm -mt-1 flex items-center">
612
- {#if isMultiple}
613
- <button
614
- type="button"
615
- onclick={() => _selectedColl.addMany(options.items)}
616
- class={twMerge(
617
- "control flex items-center p-1 m-1 text-sm opacity-75 underline rounded",
618
- "hover:opacity-100 focus-visible:outline-neutral-400 focus-visible:opacity-100"
619
- )}
620
- tabindex={4}
621
- disabled={!options.size}
622
- >
623
- {@html t("select_all")}
624
- </button>
625
- {/if}
626
- <button
627
- type="button"
628
- onclick={() => {
629
- _selectedColl.clear();
630
- input?.focus();
631
- }}
632
- class={twMerge(
633
- "control flex items-center p-1 m-1 text-sm opacity-75 underline rounded",
634
- "hover:opacity-100 focus-visible:outline-neutral-400 focus-visible:opacity-100"
635
- )}
636
- class:opacity-20={!selected.items.length}
637
- tabindex={5}
638
- disabled={!selected.items.length}
639
- >
640
- {@html t(cardinality === 1 ? "clear" : "clear_all")}
641
- </button>
642
-
643
- <span class="p-1 m-1 text-sm">&nbsp;</span>
644
- <span class="flex-1 block justify-end opacity-50 text-right text-sm p-1 pr-2">
645
- {selected.items.length}
646
- {#if cardinality > 0 && cardinality < Infinity}
647
- {@html t("cardinality_of")} {cardinality}
648
- {/if}
649
- {@html t("cardinality_selected")}
650
- </span>
651
- </div>
652
-
653
- <!-- {#if options.items.length} -->
654
- <div
655
- class={[
656
- "options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
657
- "h-55 max-h-55",
658
- ]}
659
- bind:this={optionsBox}
660
- tabindex="-1"
661
- >
662
- {#if isFetching && !options.items.length}
663
- <!-- <div class="p-4 opacity-50"> -->
664
- <div class="flex opacity-50 text-sm h-full items-center justify-center">
665
- <Spinner class="w-4" />
666
- </div>
667
- {:else if !options.items.length && !allowUnknown}
668
- <div class="flex opacity-50 text-sm h-full items-center justify-center">
669
- {@html t("no_results")}
670
- </div>
671
- {/if}
672
-
673
- {#if !isFetching && allowUnknown && innerValue && !have_option_label_like(options.items, innerValue)}
674
- <div class="px-1">
573
+ <div class="pt-0 md:pt-[20vh] w-full">
574
+ <div class="pointer-events-auto">
575
+ <InputWrap
576
+ size={renderSize}
577
+ class={twMerge("m-2 mb-12 shadow-xl", classModalField)}
578
+ classInputBoxWrap={twMerge(
579
+ // always look like focused
580
+ `border border-input-accent dark:border-input-accent-dark`,
581
+ `ring-input-accent/20 dark:ring-input-accent-dark/20 ring-4`
582
+ )}
583
+ {id}
584
+ {required}
585
+ >
586
+ <input
587
+ bind:value={innerValue}
588
+ bind:this={input}
589
+ {type}
590
+ {id}
591
+ class={twMerge("form-input", renderSize, classInput)}
592
+ tabindex={1}
593
+ {required}
594
+ {disabled}
595
+ placeholder={searchPlaceholder ??
596
+ t(allowUnknown ? "search_submit_placeholder" : "search_placeholder")}
597
+ onkeydown={(e) => {
598
+ if (e.key === "Enter") {
599
+ e.preventDefault();
600
+ try_submit();
601
+ }
602
+ }}
603
+ autocomplete="off"
604
+ name={`rand-${Math.random().toString(36).slice(2)}`}
605
+ {...rest}
606
+ />
607
+
608
+ {#snippet inputBelow()}
609
+ <div class="h-full border-t p-2 border-black/20">
610
+ <div class="text-sm -mt-1 flex items-center">
611
+ {#if isMultiple}
612
+ <button
613
+ type="button"
614
+ onclick={() => _selectedColl.addMany(options.items)}
615
+ class={twMerge(
616
+ "control flex items-center p-1 m-1 text-sm opacity-75 underline rounded",
617
+ "hover:opacity-100 focus-visible:outline-neutral-400 focus-visible:opacity-100"
618
+ )}
619
+ tabindex={4}
620
+ disabled={!options.size}
621
+ >
622
+ {@html t("select_all")}
623
+ </button>
624
+ {/if}
675
625
  <button
676
626
  type="button"
677
- bind:this={addNewBtn}
678
- onclick={add_new}
627
+ onclick={() => {
628
+ _selectedColl.clear();
629
+ input?.focus();
630
+ }}
679
631
  class={twMerge(
680
- BTN_CLS,
681
- classOption,
682
- isAddNewBtnActive && classOptionActive
632
+ "control flex items-center p-1 m-1 text-sm opacity-75 underline rounded",
633
+ "hover:opacity-100 focus-visible:outline-neutral-400 focus-visible:opacity-100"
683
634
  )}
635
+ class:opacity-20={!selected.items.length}
636
+ tabindex={5}
637
+ disabled={!selected.items.length}
684
638
  >
685
- {t("add_new", { value: innerValue })}
639
+ {@html t(cardinality === 1 ? "clear" : "clear_all")}
686
640
  </button>
687
- </div>
688
- {/if}
689
641
 
690
- {#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
691
- {#if _optgroup}
692
- <div
693
- class={twMerge(
694
- "text-sm capitalize opacity-50 border-b border-black/10 mb-0.5 p-1 mx-1",
695
- classOptgroup
696
- )}
642
+ <span class="p-1 m-1 text-sm">&nbsp;</span>
643
+ <span
644
+ class="flex-1 block justify-end opacity-50 text-right text-sm p-1 pr-2"
697
645
  >
698
- {_optgroup}
699
- </div>
700
- {/if}
701
- <ul class="space-y-0.5">
702
- <!-- {#each options.items as item} -->
703
- {#each _opts as item (item[itemIdPropName])}
704
- {@const active =
705
- item[itemIdPropName] === options.active?.[itemIdPropName]}
706
- {@const isSelected =
707
- selected.items && _selectedColl.exists(item[itemIdPropName])}
708
- <li class:active class="px-1">
646
+ {selected.items.length}
647
+ {#if cardinality > 0 && cardinality < Infinity}
648
+ {@html t("cardinality_of")} {cardinality}
649
+ {/if}
650
+ {@html t("cardinality_selected")}
651
+ </span>
652
+ </div>
653
+
654
+ <!-- {#if options.items.length} -->
655
+ <div
656
+ class={[
657
+ "options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
658
+ "h-55 max-h-55",
659
+ ]}
660
+ bind:this={optionsBox}
661
+ tabindex="-1"
662
+ >
663
+ {#if isFetching && !options.items.length}
664
+ <!-- <div class="p-4 opacity-50"> -->
665
+ <div class="flex opacity-50 text-sm h-full items-center justify-center">
666
+ <Spinner class="w-4" />
667
+ </div>
668
+ {:else if !options.items.length && !allowUnknown}
669
+ <div class="flex opacity-50 text-sm h-full items-center justify-center">
670
+ {@html t("no_results")}
671
+ </div>
672
+ {/if}
673
+
674
+ {#if !isFetching && allowUnknown && innerValue && !have_option_label_like(options.items, innerValue)}
675
+ <div class="px-1">
709
676
  <button
710
677
  type="button"
711
- id={btn_id(item[itemIdPropName])}
712
- onclick={() => {
713
- if (isMultiple) {
714
- if (selected.isFull && !_selectedColl.exists(item)) {
715
- return notifications?.error(t("cardinality_full"), {
716
- ttl: 1000,
717
- });
718
- }
719
- _selectedColl.toggleAdd(item);
720
- } else {
721
- _selectedColl.clear();
722
- _selectedColl.add(item);
723
- submit();
724
- }
725
- }}
726
- class:active
727
- class:selected={isSelected}
678
+ bind:this={addNewBtn}
679
+ onclick={add_new}
728
680
  class={twMerge(
729
681
  BTN_CLS,
730
- isSelected && "bg-neutral-200 dark:bg-neutral-800",
731
682
  classOption,
732
- // active && "border-neutral-400",
733
- active && classOptionActive
683
+ isAddNewBtnActive && classOptionActive
734
684
  )}
735
- tabindex="-1"
736
- role="checkbox"
737
- aria-checked={isSelected}
738
685
  >
739
- {#if showIcons}
740
- <span class={isSelected ? "opacity-100" : "opacity-25"}>
741
- {#if isMultiple}
742
- {#if isSelected}
743
- {@html iconCheckboxCheck()}
744
- {:else}
745
- {@html iconCheckboxEmpty()}
746
- {/if}
747
- {:else if isSelected}
748
- {@html iconRadioCheck()}
749
- {:else}
750
- {@html iconRadioEmpty()}
751
- {/if}
752
- </span>
753
- {/if}
754
- <span
755
- class={twMerge(
756
- "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
757
- )}>{_renderOptionLabel(item)}</span
758
- >
686
+ {t("add_new", { value: innerValue })}
759
687
  </button>
760
- </li>
688
+ </div>
689
+ {/if}
690
+
691
+ {#each _normalize_and_group_options(options.items) as [_optgroup, _opts]}
692
+ {#if _optgroup}
693
+ <div
694
+ class={twMerge(
695
+ "text-sm capitalize opacity-50 border-b border-black/10 mb-0.5 p-1 mx-1",
696
+ classOptgroup
697
+ )}
698
+ >
699
+ {_optgroup}
700
+ </div>
701
+ {/if}
702
+ <ul class="space-y-0.5">
703
+ <!-- {#each options.items as item} -->
704
+ {#each _opts as item (item[itemIdPropName])}
705
+ {@const active =
706
+ item[itemIdPropName] === options.active?.[itemIdPropName]}
707
+ {@const isSelected =
708
+ selected.items && _selectedColl.exists(item[itemIdPropName])}
709
+ <li class:active class="px-1">
710
+ <button
711
+ type="button"
712
+ id={btn_id(item[itemIdPropName])}
713
+ onclick={() => {
714
+ if (isMultiple) {
715
+ if (selected.isFull && !_selectedColl.exists(item)) {
716
+ return notifications?.error(t("cardinality_full"), {
717
+ ttl: 1000,
718
+ });
719
+ }
720
+ _selectedColl.toggleAdd(item);
721
+ } else {
722
+ _selectedColl.clear();
723
+ _selectedColl.add(item);
724
+ submit();
725
+ }
726
+ }}
727
+ class:active
728
+ class:selected={isSelected}
729
+ class={twMerge(
730
+ BTN_CLS,
731
+ isSelected && "bg-neutral-200 dark:bg-neutral-800",
732
+ classOption,
733
+ // active && "border-neutral-400",
734
+ active && classOptionActive
735
+ )}
736
+ tabindex="-1"
737
+ role="checkbox"
738
+ aria-checked={isSelected}
739
+ >
740
+ {#if showIcons}
741
+ <span class={isSelected ? "opacity-100" : "opacity-25"}>
742
+ {#if isMultiple}
743
+ {#if isSelected}
744
+ {@html iconCheckboxCheck()}
745
+ {:else}
746
+ {@html iconCheckboxEmpty()}
747
+ {/if}
748
+ {:else if isSelected}
749
+ {@html iconRadioCheck()}
750
+ {:else}
751
+ {@html iconRadioEmpty()}
752
+ {/if}
753
+ </span>
754
+ {/if}
755
+ <span
756
+ class={twMerge(
757
+ "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap"
758
+ )}>{_renderOptionLabel(item)}</span
759
+ >
760
+ </button>
761
+ </li>
762
+ {/each}
763
+ </ul>
761
764
  {/each}
762
- </ul>
763
- {/each}
764
- </div>
765
- <!-- {/if} -->
766
- <div class="p-2 px-3 flex items-end justify-between">
767
- <div class="text-sm opacity-50">
768
- <!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
769
- {#if allowUnknown}
770
- {@html t("unknown_allowed")}
771
- {:else}
772
- {@html t("unknown_not_allowed")}
765
+ </div>
766
+ <!-- {/if} -->
767
+ <div class="p-2 px-3 flex items-end justify-between">
768
+ <div class="text-sm opacity-50">
769
+ <!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
770
+ {#if allowUnknown}
771
+ {@html t("unknown_allowed")}
772
+ {:else}
773
+ {@html t("unknown_not_allowed")}
774
+ {/if}
775
+ </div>
776
+ <div>
777
+ <Button
778
+ class="control"
779
+ type="button"
780
+ variant="primary"
781
+ onclick={async (e) => {
782
+ e.preventDefault();
783
+ try_submit(true);
784
+ }}
785
+ tabindex={3}
786
+ >
787
+ {@html t("submit")}
788
+ </Button>
789
+ </div>
790
+ </div>
791
+ </div>
792
+ {/snippet}
793
+
794
+ {#snippet inputAfter()}
795
+ <div class="flex pl-2 items-center justify-center opacity-50">
796
+ {#if isFetching}
797
+ <Spinner class="w-4" />
773
798
  {/if}
774
799
  </div>
775
- <div>
776
- <Button
777
- class="control"
800
+ <div class="flex pl-2 pr-1 items-center justify-center">
801
+ <button
778
802
  type="button"
779
- variant="primary"
780
- onclick={async (e) => {
803
+ class={twMerge(
804
+ "opacity-75 rounded",
805
+ "hover:opacity-100 hover:bg-neutral-200 dark:hover:bg-neutral-800",
806
+ "focus-visible:opacity-100 focus-visible:outline-0",
807
+ "focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
808
+ )}
809
+ use:tooltip
810
+ aria-label={t("x_close")}
811
+ onclick={(e) => {
781
812
  e.preventDefault();
782
- try_submit(true);
813
+ if (innerValue.trim() == "") {
814
+ return escape();
815
+ }
816
+ innerValue = "";
817
+ input?.focus();
783
818
  }}
784
- tabindex={3}
819
+ tabindex={2}
785
820
  >
786
- {@html t("submit")}
787
- </Button>
821
+ <X class="m-2 size-6" />
822
+ </button>
788
823
  </div>
789
- </div>
790
- </div>
791
- {/snippet}
792
-
793
- {#snippet inputAfter()}
794
- <div class="flex pl-2 items-center justify-center opacity-50">
795
- {#if isFetching}
796
- <Spinner class="w-4" />
797
- {/if}
798
- </div>
799
- <div class="flex pl-2 pr-1 items-center justify-center">
800
- <button
801
- type="button"
802
- class={twMerge(
803
- "opacity-50 rounded",
804
- "hover:opacity-100 hover:bg-neutral-200 dark:hover:bg-neutral-800",
805
- "focus-visible:opacity-100 focus-visible:outline-0",
806
- "focus-visible:bg-neutral-200 dark:focus-visible:bg-neutral-800"
807
- )}
808
- use:tooltip
809
- aria-label={t("x_close")}
810
- onclick={(e) => {
811
- e.preventDefault();
812
- if (innerValue.trim() == "") {
813
- return escape();
814
- }
815
- innerValue = "";
816
- input?.focus();
817
- }}
818
- tabindex={2}
819
- >
820
- <X class="m-2 size-4 " />
821
- </button>
822
- </div>
823
- {/snippet}
824
-
825
- {#snippet inputBefore()}
826
- <div class="flex flex-col items-center justify-center pl-3 opacity-50">
827
- {@html iconSearch({ size: 14 })}
828
- </div>
829
- {/snippet}
830
- </InputWrap>
831
- </Modal>
824
+ {/snippet}
825
+
826
+ {#snippet inputBefore()}
827
+ <div class="flex flex-col items-center justify-center pl-3 opacity-75">
828
+ {@html iconSearch({ size: 19, strokeWidth: 3 })}
829
+ </div>
830
+ {/snippet}
831
+ </InputWrap>
832
+ </div>
833
+ </div>
834
+ </ModalDialog>
832
835
  </div>
833
836
 
834
837
  <style>
@@ -2,7 +2,7 @@ import { ItemCollection, 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";
5
- import Modal from "../Modal/Modal.svelte";
5
+ import { ModalDialog } from "../ModalDialog/index.js";
6
6
  import { NotificationsStack } from "../Notifications/index.js";
7
7
  import type { THC } from "../Thc/Thc.svelte";
8
8
  export interface Option {
@@ -15,7 +15,7 @@ type SnippetWithId = Snippet<[{
15
15
  export interface Props extends Record<string, any> {
16
16
  trigger?: Snippet<[{
17
17
  value: string;
18
- modal: Modal;
18
+ modal: ModalDialog;
19
19
  }]>;
20
20
  input?: HTMLInputElement;
21
21
  value: string;