@marianmeres/stuic 3.130.0 → 3.131.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.
@@ -1,6 +1,16 @@
1
1
  <script lang="ts" module>
2
2
  import { createClog } from "@marianmeres/clog";
3
- import { iconSearch, iconCheck, iconCircle, iconSquare } from "../../icons/index.js";
3
+ import {
4
+ iconSearch,
5
+ iconCheck,
6
+ iconCircle,
7
+ iconSquare,
8
+ iconChevronUp,
9
+ iconChevronDown,
10
+ iconArrowUpToLine,
11
+ iconArrowDownToLine,
12
+ iconTrash,
13
+ } from "../../icons/index.js";
4
14
  import { ItemCollection, type Item } from "@marianmeres/item-collection";
5
15
  import { Debounced, watch } from "runed";
6
16
  import { onDestroy, tick, type Snippet } from "svelte";
@@ -73,6 +83,13 @@
73
83
  renderOptionLabel?: (item: Item) => string;
74
84
  renderOptionGroup?: (s: string) => string;
75
85
  allowUnknown?: boolean;
86
+ /**
87
+ * Opt-in: when true (and multi-select), exposes a dedicated "Arrange" screen in the
88
+ * modal that lets the user manually reorder the current selection (buttons only, no
89
+ * drag), plus "Sort A–Z" / "Reverse" shortcuts. The chosen order is what gets
90
+ * serialized to `value` on submit. No-op for single-select. Default `false`.
91
+ */
92
+ ordered?: boolean;
76
93
  showIconsCheckbox?: boolean;
77
94
  showIconsRadio?: boolean;
78
95
  searchPlaceholder?: string;
@@ -101,11 +118,28 @@
101
118
  cardinality_full: "Max selection reached",
102
119
  select_from_list: "Please select from the list only",
103
120
  x_close: "Clear input or close [esc]",
121
+ close: "Close [esc]",
104
122
  unknown_allowed: "Select or type and submit",
105
123
  unknown_not_allowed: "Select from the list",
106
124
  no_results: "No results found.",
107
125
  add_new: 'Add "{{value}}"...',
108
126
  click_add_new: "You must add the value to continue",
127
+ //
128
+ pick_tab: "Pick",
129
+ arrange_tab: "Arrange ({{value}})",
130
+ arrange_help: "Reorder the selected items. Use the buttons to move them.",
131
+ sort_az: "Sort A–Z",
132
+ reverse: "Reverse",
133
+ move_up: "Move up",
134
+ move_down: "Move down",
135
+ move_to_top: "Move to top",
136
+ move_to_bottom: "Move to bottom",
137
+ remove_item: "Remove",
138
+ moved_up: "Moved {{value}} up",
139
+ moved_down: "Moved {{value}} down",
140
+ removed_item: "Removed {{value}}",
141
+ sorted_az: "Sorted A to Z",
142
+ reversed: "Order reversed",
109
143
  };
110
144
  let out = m[k] ?? fallback ?? k;
111
145
 
@@ -177,6 +211,7 @@
177
211
  renderOptionLabel,
178
212
  renderOptionGroup = (s: string) => `${s}`.replaceAll("_", " "),
179
213
  allowUnknown = false,
214
+ ordered = false,
180
215
  showIconsCheckbox = true,
181
216
  showIconsRadio = false,
182
217
  searchPlaceholder,
@@ -229,6 +264,21 @@
229
264
  let isMultiple = $derived(cardinality > 1);
230
265
  let showIcons = $derived(isMultiple ? showIconsCheckbox : showIconsRadio);
231
266
 
267
+ // the "Arrange" (manual ordering) feature is opt-in and only meaningful for multi-select
268
+ let canArrange = $derived(ordered && isMultiple);
269
+ // false = "Pick" screen (search + options), true = "Arrange" screen (reorder selection)
270
+ let arrangeMode = $state(false);
271
+ // aria-live announcement text for reorder/remove actions
272
+ let liveAnnouncement = $state("");
273
+ // the arrange list scroller (for focus management)
274
+ let arrangeListBox: HTMLDivElement | undefined = $state();
275
+ // hydrate-once tracker (ordered mode): the `value` we last loaded the selection from
276
+ let hydratedValue: string | undefined = undefined;
277
+ // keep arrange state clean if the feature gets disabled at runtime (e.g. cardinality -> 1)
278
+ $effect(() => {
279
+ if (!canArrange && arrangeMode) arrangeMode = false;
280
+ });
281
+
232
282
  //
233
283
  let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
234
284
  enabled: true,
@@ -294,7 +344,13 @@
294
344
 
295
345
  // reconfigure if the prop ever changes during runtime (most likely will NOT)
296
346
  $effect(() => {
297
- _selectedColl.configure({ cardinality });
347
+ // In "arrange" mode we UNSET the sortFn (item-collection: `null` unsets, a function
348
+ // keeps it) so the selection keeps its manual/insertion order instead of being
349
+ // auto-sorted alphabetically. Default mode keeps the alpha sortFn => byte-identical.
350
+ _selectedColl.configure({
351
+ cardinality,
352
+ sortFn: canArrange ? (null as any) : sortFn,
353
+ });
298
354
  // trim excess selections if cardinality was reduced
299
355
  if (_selectedColl.size > cardinality) {
300
356
  const trimmed = _selectedColl.items.slice(0, cardinality);
@@ -356,15 +412,35 @@
356
412
  }
357
413
  );
358
414
 
415
+ function _hydrateSelectedFromValue(v: string) {
416
+ _selectedColl.clear().addMany(maybeJsonParse(v) as Item[]);
417
+ // IMPORTANT: focus first selected so it scrolls into view on open
418
+ if (_selectedColl.size) {
419
+ waitForNextRepaint().then(() => {
420
+ if (!isUnmounted) _optionsColl.setActive(_selectedColl.items[0]);
421
+ });
422
+ }
423
+ }
424
+
359
425
  $effect(() => {
360
- if (modalDialog.visibility().visible && touch) {
361
- _selectedColl.clear().addMany(maybeJsonParse(value) as Item[]);
362
- // IMPORTANT: focus first selected so it scrolls into view on open
363
- if (_selectedColl.size) {
364
- waitForNextRepaint().then(() => {
365
- if (!isUnmounted) _optionsColl.setActive(_selectedColl.items[0]);
366
- });
426
+ const visible = modalDialog.visibility().visible;
427
+ if (canArrange) {
428
+ // ORDERED MODE: hydrate the selection from `value` ONLY on open / external value
429
+ // change — deliberately NOT on every fetch (`touch`), otherwise a manual reorder
430
+ // (or any not-yet-submitted pick) would be silently reset on the next keystroke.
431
+ const v = value;
432
+ if (!visible) {
433
+ hydratedValue = undefined;
434
+ return;
367
435
  }
436
+ if (hydratedValue === v) return;
437
+ hydratedValue = v;
438
+ // ensure the sortFn is unset before addMany, so the saved order survives the load
439
+ _selectedColl.configure({ sortFn: null as any }, false);
440
+ _hydrateSelectedFromValue(v);
441
+ } else {
442
+ // DEFAULT MODE: unchanged behavior (re-sync to `value` whenever visible & touched)
443
+ if (visible && touch) _hydrateSelectedFromValue(value);
368
444
  }
369
445
  });
370
446
 
@@ -384,6 +460,87 @@
384
460
  return prefix + strHash(`${id}`.repeat(3));
385
461
  }
386
462
 
463
+ // --- Arrange (ordered) screen helpers ---
464
+
465
+ function arrange_row_id(id: string | number) {
466
+ return btn_id(id, "arr-");
467
+ }
468
+
469
+ function _announce(key: string, item: Item) {
470
+ liveAnnouncement = t(key, { value: _renderOptionLabel(item) }) as string;
471
+ }
472
+
473
+ // re-focus the same logical button on the row that moved, so repeated presses keep
474
+ // walking the item; if that button is now disabled (a boundary), fall back to an enabled one
475
+ function focusRowButton(itemId: string | number, which: "up" | "down") {
476
+ tick().then(() => {
477
+ const row = arrangeListBox?.querySelector(`#${arrange_row_id(itemId)}`);
478
+ if (!row) return;
479
+ let btn = row.querySelector<HTMLButtonElement>(`[data-arrange-btn="${which}"]`);
480
+ if (!btn || btn.disabled) {
481
+ btn =
482
+ row.querySelector<HTMLButtonElement>(
483
+ `[data-arrange-btn="up"]:not([disabled])`
484
+ ) ||
485
+ row.querySelector<HTMLButtonElement>(
486
+ `[data-arrange-btn="down"]:not([disabled])`
487
+ );
488
+ }
489
+ btn?.focus();
490
+ });
491
+ }
492
+
493
+ function moveItem(from: number, to: number) {
494
+ if (to < 0 || to >= _selectedColl.size || from === to) return;
495
+ const item = _selectedColl.items[from];
496
+ if (!_selectedColl.move(from, to)) return;
497
+ if (item) {
498
+ _announce(to < from ? "moved_up" : "moved_down", item);
499
+ focusRowButton(item[itemIdPropName], to < from ? "up" : "down");
500
+ }
501
+ }
502
+
503
+ function removeItem(i: number) {
504
+ const item = _selectedColl.items[i];
505
+ _selectedColl.removeAt(i);
506
+ if (item) _announce("removed_item", item);
507
+ tick().then(() => {
508
+ if (!_selectedColl.size) return enterPick();
509
+ const next = _selectedColl.items[Math.min(i, _selectedColl.size - 1)];
510
+ if (next) focusRowButton(next[itemIdPropName], "down");
511
+ });
512
+ }
513
+
514
+ // one-shot sort by visible label (does NOT re-enable auto-sort; subsequent adds still append)
515
+ function sortAZ() {
516
+ _selectedColl.sort((a, b) =>
517
+ _renderOptionLabel(a).localeCompare(_renderOptionLabel(b), undefined, {
518
+ sensitivity: "base",
519
+ })
520
+ );
521
+ liveAnnouncement = t("sorted_az") as string;
522
+ }
523
+
524
+ // no reverse() on the collection; snapshot, clear (no publish), re-add in reversed order
525
+ function reverse() {
526
+ const reversed = [...selected.items].reverse();
527
+ _selectedColl.clear(false).addMany(reversed);
528
+ liveAnnouncement = t("reversed") as string;
529
+ }
530
+
531
+ function enterArrange() {
532
+ if (!selected.items.length) return;
533
+ arrangeMode = true;
534
+ input?.blur(); // dismiss the soft keyboard (no text input on this screen)
535
+ const first = selected.items[0];
536
+ if (first) focusRowButton(first[itemIdPropName], "down");
537
+ }
538
+
539
+ function enterPick() {
540
+ arrangeMode = false;
541
+ tick().then(() => input?.focus());
542
+ }
543
+
387
544
  // "inner" submit
388
545
  function try_submit(force = false) {
389
546
  // clog("try_submit", innerValue, _selectedColl.dump());
@@ -440,6 +597,7 @@
440
597
  // clog("modal submit", $state.snapshot(selected.items));
441
598
  value = JSON.stringify(selected.items);
442
599
  innerValue = "";
600
+ arrangeMode = false;
443
601
  _optionsColl.clear();
444
602
  modalDialog.close();
445
603
  _dispatch_change_to_owner();
@@ -449,6 +607,7 @@
449
607
  // clears state and dispatches change; close is handled by ModalDialog's preEscapeClose
450
608
  function escape() {
451
609
  innerValue = "";
610
+ arrangeMode = false;
452
611
  _optionsColl.clear();
453
612
  _dispatch_change_to_owner();
454
613
  }
@@ -506,7 +665,9 @@
506
665
  <!-- this must be on window as we're catching any typing anywhere -->
507
666
  <svelte:window
508
667
  onkeydown={(e) => {
509
- if (modalDialog.visibility().visible) {
668
+ // on the Arrange screen the reorder buttons own the keyboard; the option-list
669
+ // navigation + "any key focuses search" behavior must NOT run there
670
+ if (modalDialog.visibility().visible && !arrangeMode) {
510
671
  // arrow navigation
511
672
  if (["ArrowDown", "ArrowUp"].includes(e.key)) {
512
673
  e.preventDefault();
@@ -602,6 +763,11 @@
602
763
  >
603
764
  <div class="pt-0 md:pt-[20vh] w-full">
604
765
  <div class="pointer-events-auto">
766
+ <!-- screen-reader announcements for reorder / remove actions -->
767
+ <div class="sr-only" aria-live="polite" aria-atomic="true">
768
+ {liveAnnouncement}
769
+ </div>
770
+
605
771
  <InputWrap
606
772
  size={renderSize}
607
773
  class={twMerge("m-1 sm:m-2 mb-12 shadow-xl", classModalField)}
@@ -613,12 +779,75 @@
613
779
  {id}
614
780
  {required}
615
781
  >
782
+ {#snippet inputAbove()}
783
+ {#if canArrange}
784
+ <!-- Pick | Arrange tab header — lives INSIDE the card (solid bg) -->
785
+ <div
786
+ class="relative flex items-stretch px-2 border-b"
787
+ style={`border-color: var(--stuic-field-options-divider); min-height: var(--stuic-input-min-height-${renderSize}, 3rem);`}
788
+ >
789
+ <!-- full-height tabs => titles are vertically centered in the row -->
790
+ <div role="tablist" class="flex items-stretch gap-1">
791
+ <button
792
+ type="button"
793
+ role="tab"
794
+ aria-selected={!arrangeMode}
795
+ onclick={enterPick}
796
+ class={twMerge(
797
+ // keep font-weight constant across states so the tab doesn't reflow
798
+ "flex items-center px-3 -mb-px text-sm font-semibold border-b-2",
799
+ !arrangeMode
800
+ ? "border-(--stuic-input-accent) text-(--stuic-input-accent)"
801
+ : "border-transparent stuic-field-options-muted"
802
+ )}
803
+ >
804
+ {@html t("pick_tab")}
805
+ </button>
806
+ <button
807
+ type="button"
808
+ role="tab"
809
+ aria-selected={arrangeMode}
810
+ disabled={!selected.items.length}
811
+ onclick={enterArrange}
812
+ class={twMerge(
813
+ // keep font-weight constant across states so the tab doesn't reflow
814
+ "flex items-center px-3 -mb-px text-sm font-semibold border-b-2",
815
+ arrangeMode
816
+ ? "border-(--stuic-input-accent) text-(--stuic-input-accent)"
817
+ : "border-transparent stuic-field-options-muted"
818
+ )}
819
+ >
820
+ {@html t("arrange_tab", { value: selected.items.length })}
821
+ </button>
822
+ </div>
823
+
824
+ <!-- modal close lives in the tab header whenever tabs exist (both screens).
825
+ Absolutely positioned + height-matched row, so it never causes a jump. -->
826
+ <div class="absolute inset-y-0 right-1 flex items-center">
827
+ <Button
828
+ x
829
+ size="sm"
830
+ variant="ghost"
831
+ roundedFull
832
+ type="button"
833
+ tooltip={t("close")}
834
+ onclick={(e) => {
835
+ e.preventDefault();
836
+ escape();
837
+ modalDialog.close();
838
+ }}
839
+ />
840
+ </div>
841
+ </div>
842
+ {/if}
843
+ {/snippet}
844
+
616
845
  <input
617
846
  bind:value={innerValue}
618
847
  bind:this={input}
619
848
  {type}
620
849
  {id}
621
- class={twMerge(renderSize, classInput)}
850
+ class={twMerge(renderSize, classInput, arrangeMode && "hidden")}
622
851
  tabindex={1}
623
852
  {required}
624
853
  {disabled}
@@ -638,203 +867,353 @@
638
867
 
639
868
  {#snippet inputBelow()}
640
869
  <div
641
- class="h-full border-t p-2"
870
+ class={twMerge("h-full p-2", !arrangeMode && "border-t")}
642
871
  style="border-color: var(--stuic-field-options-divider);"
643
872
  >
644
- <div class="text-sm -mt-1 flex items-center">
645
- {#if isMultiple}
873
+ {#snippet footerSubmitBtn()}
874
+ <div>
875
+ <Button
876
+ class="control"
877
+ type="button"
878
+ intent="primary"
879
+ onclick={async (e) => {
880
+ e.preventDefault();
881
+ try_submit(true);
882
+ }}
883
+ tabindex={3}
884
+ >
885
+ {@html t("submit")}
886
+ </Button>
887
+ </div>
888
+ {/snippet}
889
+
890
+ {#if canArrange && arrangeMode}
891
+ <!-- ARRANGE screen: reorder the current selection (buttons only, no drag) -->
892
+ <div class="text-sm -mt-1 flex items-center">
646
893
  <button
647
894
  type="button"
648
- onclick={() => _selectedColl.addMany(options.items)}
895
+ onclick={sortAZ}
896
+ disabled={selected.items.length < 2}
649
897
  class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
650
898
  tabindex={4}
651
- disabled={!options.size}
652
899
  >
653
- {@html t("select_all")}
900
+ {@html t("sort_az")}
654
901
  </button>
655
- {/if}
656
- <button
657
- type="button"
658
- onclick={() => {
659
- _selectedColl.clear();
660
- input?.focus();
661
- }}
662
- class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
663
- data-disabled={!selected.items.length || undefined}
664
- tabindex={5}
665
- disabled={!selected.items.length}
666
- >
667
- {@html t(cardinality === 1 ? "clear" : "clear_all")}
668
- </button>
902
+ <button
903
+ type="button"
904
+ onclick={reverse}
905
+ disabled={selected.items.length < 2}
906
+ class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
907
+ tabindex={5}
908
+ >
909
+ {@html t("reverse")}
910
+ </button>
911
+ <span
912
+ class="flex-1 block justify-end text-right text-xs p-1 pr-2 stuic-field-options-muted"
913
+ >
914
+ {selected.items.length}
915
+ {#if cardinality > 0 && cardinality < Infinity}
916
+ {@html t("cardinality_of")} {cardinality}
917
+ {/if}
918
+ {@html t("cardinality_selected")}
919
+ </span>
920
+ </div>
669
921
 
670
- <span class="p-1 m-1 text-sm">&nbsp;</span>
671
- <span
672
- class="flex-1 block justify-end text-right text-xs p-1 pr-2 stuic-field-options-muted"
922
+ <div
923
+ class={[
924
+ "options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
925
+ "h-55 max-h-55",
926
+ ]}
927
+ bind:this={arrangeListBox}
928
+ tabindex="-1"
673
929
  >
674
- {selected.items.length}
675
- {#if cardinality > 0 && cardinality < Infinity}
676
- {@html t("cardinality_of")} {cardinality}
677
- {/if}
678
- {@html t("cardinality_selected")}
679
- </span>
680
- </div>
930
+ {#each selected.items as item, i (item[itemIdPropName])}
931
+ {@const last = selected.items.length - 1}
932
+ <div
933
+ id={arrange_row_id(item[itemIdPropName])}
934
+ class="flex items-center gap-1 px-1 py-0.5"
935
+ >
936
+ <span class="flex-1 min-w-0 truncate text-sm"
937
+ >{_renderOptionLabel(item)}</span
938
+ >
939
+ <span class="hidden sm:inline-flex">
940
+ <Button
941
+ iconButton
942
+ variant="ghost"
943
+ type="button"
944
+ aria-label={t("move_to_top")}
945
+ tooltip={t("move_to_top")}
946
+ data-arrange-btn="top"
947
+ disabled={i === 0}
948
+ onclick={() => moveItem(i, 0)}
949
+ >
950
+ {@html iconArrowUpToLine({ size: 18 })}
951
+ </Button>
952
+ </span>
953
+ <Button
954
+ iconButton
955
+ variant="ghost"
956
+ type="button"
957
+ aria-label={t("move_up")}
958
+ tooltip={t("move_up")}
959
+ data-arrange-btn="up"
960
+ disabled={i === 0}
961
+ onclick={() => moveItem(i, i - 1)}
962
+ >
963
+ {@html iconChevronUp({ size: 18 })}
964
+ </Button>
965
+ <Button
966
+ iconButton
967
+ variant="ghost"
968
+ type="button"
969
+ aria-label={t("move_down")}
970
+ tooltip={t("move_down")}
971
+ data-arrange-btn="down"
972
+ disabled={i === last}
973
+ onclick={() => moveItem(i, i + 1)}
974
+ >
975
+ {@html iconChevronDown({ size: 18 })}
976
+ </Button>
977
+ <span class="hidden sm:inline-flex">
978
+ <Button
979
+ iconButton
980
+ variant="ghost"
981
+ type="button"
982
+ aria-label={t("move_to_bottom")}
983
+ tooltip={t("move_to_bottom")}
984
+ data-arrange-btn="bottom"
985
+ disabled={i === last}
986
+ onclick={() => moveItem(i, last)}
987
+ >
988
+ {@html iconArrowDownToLine({ size: 18 })}
989
+ </Button>
990
+ </span>
991
+ <Button
992
+ iconButton
993
+ variant="ghost"
994
+ type="button"
995
+ aria-label={t("remove_item")}
996
+ tooltip={t("remove_item")}
997
+ data-arrange-btn="remove"
998
+ onclick={() => removeItem(i)}
999
+ >
1000
+ {@html iconTrash({ size: 18 })}
1001
+ </Button>
1002
+ </div>
1003
+ {/each}
1004
+ </div>
681
1005
 
682
- <!-- {#if options.items.length} -->
683
- <div
684
- id={`${id}-options`}
685
- class={[
686
- "options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
687
- "h-55 max-h-55",
688
- ]}
689
- bind:this={optionsBox}
690
- tabindex="-1"
691
- >
692
- {#if isFetching && !options.items.length}
693
- <div
694
- class="flex text-sm h-full items-center justify-center stuic-field-options-placeholder"
695
- >
696
- <Spinner class="w-4" />
1006
+ <div class="pt-3 pl-1 flex items-end justify-between">
1007
+ <div class="text-xs stuic-field-options-muted">
1008
+ {@html t("arrange_help")}
697
1009
  </div>
698
- {:else if !options.items.length && !allowUnknown}
699
- <div
700
- class="flex text-sm h-full items-center justify-center stuic-field-options-placeholder"
1010
+ {@render footerSubmitBtn()}
1011
+ </div>
1012
+ {:else}
1013
+ <div class="text-sm -mt-1 flex items-center">
1014
+ {#if isMultiple}
1015
+ <button
1016
+ type="button"
1017
+ onclick={() => _selectedColl.addMany(options.items)}
1018
+ class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
1019
+ tabindex={4}
1020
+ disabled={!options.size}
1021
+ >
1022
+ {@html t("select_all")}
1023
+ </button>
1024
+ {/if}
1025
+ <button
1026
+ type="button"
1027
+ onclick={() => {
1028
+ _selectedColl.clear();
1029
+ input?.focus();
1030
+ }}
1031
+ class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
1032
+ data-disabled={!selected.items.length || undefined}
1033
+ tabindex={5}
1034
+ disabled={!selected.items.length}
701
1035
  >
702
- {@html t("no_results")}
703
- </div>
704
- {/if}
1036
+ {@html t(cardinality === 1 ? "clear" : "clear_all")}
1037
+ </button>
705
1038
 
706
- {#if !isFetching && allowUnknown && innerValue && !have_option_label_like(options.items, innerValue)}
707
- <div class="px-1">
708
- <ListItemButton
709
- bind:el={addNewBtn}
710
- onclick={add_new}
711
- focused={isAddNewBtnActive}
712
- class={classOption}
713
- classFocused={classOptionActive}
714
- >
715
- {t("add_new", { value: innerValue })}
716
- </ListItemButton>
717
- </div>
718
- {/if}
1039
+ <span class="p-1 m-1 text-sm">&nbsp;</span>
1040
+ <span
1041
+ class="flex-1 block justify-end text-right text-xs p-1 pr-2 stuic-field-options-muted"
1042
+ >
1043
+ {selected.items.length}
1044
+ {#if cardinality > 0 && cardinality < Infinity}
1045
+ {@html t("cardinality_of")} {cardinality}
1046
+ {/if}
1047
+ {@html t("cardinality_selected")}
1048
+ </span>
1049
+ </div>
719
1050
 
720
- {#each groupedOptions as [_optgroup, _opts]}
721
- {#if _optgroup}
1051
+ <!-- {#if options.items.length} -->
1052
+ <div
1053
+ id={`${id}-options`}
1054
+ class={[
1055
+ "options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
1056
+ "h-55 max-h-55",
1057
+ ]}
1058
+ bind:this={optionsBox}
1059
+ tabindex="-1"
1060
+ >
1061
+ {#if isFetching && !options.items.length}
722
1062
  <div
723
- class={twMerge(
724
- "mb-1 p-1 text-xs font-semibold uppercase tracking-wide stuic-field-options-optgroup",
725
- classOptgroup
726
- )}
1063
+ class="flex text-sm h-full items-center justify-center stuic-field-options-placeholder"
727
1064
  >
728
- {_optgroup}
1065
+ <Spinner class="w-4" />
1066
+ </div>
1067
+ {:else if !options.items.length && !allowUnknown}
1068
+ <div
1069
+ class="flex text-sm h-full items-center justify-center stuic-field-options-placeholder"
1070
+ >
1071
+ {@html t("no_results")}
729
1072
  </div>
730
1073
  {/if}
731
- <ul role="presentation" class="space-y-1">
732
- <!-- {#each options.items as item} -->
733
- {#each _opts as item (item[itemIdPropName])}
734
- {@const active =
735
- item[itemIdPropName] === options.active?.[itemIdPropName]}
736
- {@const isSelected =
737
- selected.items && _selectedColl.exists(item[itemIdPropName])}
738
- <li class:active role="presentation" class="px-1">
739
- <ListItemButton
740
- id={btn_id(item[itemIdPropName])}
741
- onclick={() => {
742
- if (isMultiple) {
743
- if (selected.isFull && !_selectedColl.exists(item)) {
744
- return notifications?.error(t("cardinality_full"), {
745
- ttl: 1000,
746
- });
747
- }
748
- _selectedColl.toggleAdd(item);
749
- } else {
750
- _selectedColl.clear();
751
- _selectedColl.add(item);
752
- submit();
753
- }
754
- }}
755
- active={isSelected}
756
- focused={active}
757
- contentBefore={showIcons ? getIconThc(isSelected) : undefined}
758
- classContentBefore={isSelected
759
- ? "stuic-field-options-icon stuic-field-options-icon--selected"
760
- : "stuic-field-options-icon"}
761
- class={classOption}
762
- classActive={classOptionActive}
763
- classFocused={classOptionActive}
764
- tabindex={-1}
765
- role={isMultiple ? "checkbox" : "radio"}
766
- aria-checked={isSelected}
767
- >
768
- {_renderOptionLabel(item)}
769
- </ListItemButton>
770
- </li>
771
- {/each}
772
- </ul>
773
- {/each}
774
- </div>
775
- <!-- {/if} -->
776
- <div class="pt-3 pl-1 flex items-end justify-between">
777
- <div class="text-xs stuic-field-options-muted">
778
- <!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
779
- {#if allowUnknown}
780
- {@html t("unknown_allowed")}
781
- {:else}
782
- {@html t("unknown_not_allowed")}
1074
+
1075
+ {#if !isFetching && allowUnknown && innerValue && !have_option_label_like(options.items, innerValue)}
1076
+ <div class="px-1">
1077
+ <ListItemButton
1078
+ bind:el={addNewBtn}
1079
+ onclick={add_new}
1080
+ focused={isAddNewBtnActive}
1081
+ class={classOption}
1082
+ classFocused={classOptionActive}
1083
+ >
1084
+ {t("add_new", { value: innerValue })}
1085
+ </ListItemButton>
1086
+ </div>
783
1087
  {/if}
1088
+
1089
+ {#each groupedOptions as [_optgroup, _opts]}
1090
+ {#if _optgroup}
1091
+ <div
1092
+ class={twMerge(
1093
+ "mb-1 p-1 text-xs font-semibold uppercase tracking-wide stuic-field-options-optgroup",
1094
+ classOptgroup
1095
+ )}
1096
+ >
1097
+ {_optgroup}
1098
+ </div>
1099
+ {/if}
1100
+ <ul role="presentation" class="space-y-1">
1101
+ <!-- {#each options.items as item} -->
1102
+ {#each _opts as item (item[itemIdPropName])}
1103
+ {@const active =
1104
+ item[itemIdPropName] === options.active?.[itemIdPropName]}
1105
+ {@const isSelected =
1106
+ selected.items && _selectedColl.exists(item[itemIdPropName])}
1107
+ <li class:active role="presentation" class="px-1">
1108
+ <ListItemButton
1109
+ id={btn_id(item[itemIdPropName])}
1110
+ onclick={() => {
1111
+ if (isMultiple) {
1112
+ if (selected.isFull && !_selectedColl.exists(item)) {
1113
+ return notifications?.error(t("cardinality_full"), {
1114
+ ttl: 1000,
1115
+ });
1116
+ }
1117
+ _selectedColl.toggleAdd(item);
1118
+ } else {
1119
+ _selectedColl.clear();
1120
+ _selectedColl.add(item);
1121
+ submit();
1122
+ }
1123
+ }}
1124
+ active={isSelected}
1125
+ focused={active}
1126
+ contentBefore={showIcons ? getIconThc(isSelected) : undefined}
1127
+ classContentBefore={isSelected
1128
+ ? "stuic-field-options-icon stuic-field-options-icon--selected"
1129
+ : "stuic-field-options-icon"}
1130
+ class={classOption}
1131
+ classActive={classOptionActive}
1132
+ classFocused={classOptionActive}
1133
+ tabindex={-1}
1134
+ role={isMultiple ? "checkbox" : "radio"}
1135
+ aria-checked={isSelected}
1136
+ >
1137
+ {_renderOptionLabel(item)}
1138
+ </ListItemButton>
1139
+ </li>
1140
+ {/each}
1141
+ </ul>
1142
+ {/each}
784
1143
  </div>
785
- <div>
786
- <Button
787
- class="control"
788
- type="button"
789
- intent="primary"
790
- onclick={async (e) => {
791
- e.preventDefault();
792
- try_submit(true);
793
- }}
794
- tabindex={3}
795
- >
796
- {@html t("submit")}
797
- </Button>
1144
+ <!-- {/if} -->
1145
+ <div class="pt-3 pl-1 flex items-end justify-between">
1146
+ <div class="text-xs stuic-field-options-muted">
1147
+ <!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
1148
+ {#if allowUnknown}
1149
+ {@html t("unknown_allowed")}
1150
+ {:else}
1151
+ {@html t("unknown_not_allowed")}
1152
+ {/if}
1153
+ </div>
1154
+ <div>
1155
+ <Button
1156
+ class="control"
1157
+ type="button"
1158
+ intent="primary"
1159
+ onclick={async (e) => {
1160
+ e.preventDefault();
1161
+ try_submit(true);
1162
+ }}
1163
+ tabindex={3}
1164
+ >
1165
+ {@html t("submit")}
1166
+ </Button>
1167
+ </div>
798
1168
  </div>
799
- </div>
1169
+ {/if}
800
1170
  </div>
801
1171
  {/snippet}
802
1172
 
803
1173
  {#snippet inputAfter()}
804
- <div
805
- class="flex pl-2 items-center justify-center stuic-field-options-placeholder"
806
- >
807
- {#if isFetching}
808
- <Spinner class="w-4" />
1174
+ <!-- the whole search row is hidden on the Arrange screen (close moves to the tabs) -->
1175
+ {#if !arrangeMode}
1176
+ <div
1177
+ class="flex pl-2 items-center justify-center stuic-field-options-placeholder"
1178
+ >
1179
+ {#if isFetching}
1180
+ <Spinner class="w-4" />
1181
+ {/if}
1182
+ </div>
1183
+ <!-- ordered fields have a single close ✕ in the tab header, so the
1184
+ search row drops its own ✕ (avoids a confusing double-✕) -->
1185
+ {#if !canArrange}
1186
+ <div class="flex pl-2 pr-1 items-center justify-center">
1187
+ <Button
1188
+ x
1189
+ variant="ghost"
1190
+ roundedFull
1191
+ type="button"
1192
+ tooltip={t("x_close")}
1193
+ onclick={(e) => {
1194
+ e.preventDefault();
1195
+ if (innerValue.trim() == "") {
1196
+ escape();
1197
+ return modalDialog.close();
1198
+ }
1199
+ innerValue = "";
1200
+ input?.focus();
1201
+ }}
1202
+ tabindex={2}
1203
+ />
1204
+ </div>
809
1205
  {/if}
810
- </div>
811
- <div class="flex pl-2 pr-1 items-center justify-center">
812
- <Button
813
- x
814
- variant="ghost"
815
- roundedFull
816
- type="button"
817
- tooltip={t("x_close")}
818
- onclick={(e) => {
819
- e.preventDefault();
820
- if (innerValue.trim() == "") {
821
- escape();
822
- return modalDialog.close();
823
- }
824
- innerValue = "";
825
- input?.focus();
826
- }}
827
- tabindex={2}
828
- />
829
- </div>
1206
+ {/if}
830
1207
  {/snippet}
831
1208
 
832
1209
  {#snippet inputBefore()}
833
- <div
834
- class="flex flex-col items-center justify-center pl-3 stuic-field-options-muted"
835
- >
836
- {@html iconSearch({ size: 19 })}
837
- </div>
1210
+ {#if !arrangeMode}
1211
+ <div
1212
+ class="flex flex-col items-center justify-center pl-3 stuic-field-options-muted"
1213
+ >
1214
+ {@html iconSearch({ size: 19 })}
1215
+ </div>
1216
+ {/if}
838
1217
  {/snippet}
839
1218
  </InputWrap>
840
1219
  </div>
@@ -55,6 +55,13 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
55
55
  renderOptionLabel?: (item: Item) => string;
56
56
  renderOptionGroup?: (s: string) => string;
57
57
  allowUnknown?: boolean;
58
+ /**
59
+ * Opt-in: when true (and multi-select), exposes a dedicated "Arrange" screen in the
60
+ * modal that lets the user manually reorder the current selection (buttons only, no
61
+ * drag), plus "Sort A–Z" / "Reverse" shortcuts. The chosen order is what gets
62
+ * serialized to `value` on submit. No-op for single-select. Default `false`.
63
+ */
64
+ ordered?: boolean;
58
65
  showIconsCheckbox?: boolean;
59
66
  showIconsRadio?: boolean;
60
67
  searchPlaceholder?: string;
@@ -359,21 +359,22 @@ A modal-based multi-select/single-select component with search functionality, ty
359
359
 
360
360
  ### Props
361
361
 
362
- | Prop | Type | Default | Description |
363
- | ------------------- | ---------------------------------------------------------- | ---------- | --------------------------------------- |
364
- | `value` | `string` | `"[]"` | JSON array of selected items (bindable) |
365
- | `name` | `string` | - | Form field name |
366
- | `getOptions` | `(q: string, current: Item[]) => Promise<{found: Item[]}>` | - | Async function to fetch options |
367
- | `cardinality` | `number` | `Infinity` | Max selections (-1 for unlimited) |
368
- | `allowUnknown` | `boolean` | `false` | Allow typing custom values |
369
- | `renderOptionLabel` | `(item: Item) => string` | - | Custom option label renderer |
370
- | `renderOptionGroup` | `(s: string) => string` | - | Custom optgroup label renderer |
371
- | `renderValue` | `(stringifiedItems: string) => string` | - | Custom value display renderer |
372
- | `showIconsCheckbox` | `boolean` | `true` | Show checkbox icons in multi-select |
373
- | `showIconsRadio` | `boolean` | `false` | Show radio icons in single-select |
374
- | `searchPlaceholder` | `string` | - | Custom search placeholder |
375
- | `itemIdPropName` | `string` | `"id"` | Property name for item ID |
376
- | `notifications` | `NotificationsStack` | - | Notification handler for errors |
362
+ | Prop | Type | Default | Description |
363
+ | ------------------- | ---------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- |
364
+ | `value` | `string` | `"[]"` | JSON array of selected items (bindable) |
365
+ | `name` | `string` | - | Form field name |
366
+ | `getOptions` | `(q: string, current: Item[]) => Promise<{found: Item[]}>` | - | Async function to fetch options |
367
+ | `cardinality` | `number` | `Infinity` | Max selections (-1 for unlimited) |
368
+ | `allowUnknown` | `boolean` | `false` | Allow typing custom values |
369
+ | `ordered` | `boolean` | `false` | Opt-in: add an "Arrange" screen to manually order the selection (multi-select only) |
370
+ | `renderOptionLabel` | `(item: Item) => string` | - | Custom option label renderer |
371
+ | `renderOptionGroup` | `(s: string) => string` | - | Custom optgroup label renderer |
372
+ | `renderValue` | `(stringifiedItems: string) => string` | - | Custom value display renderer |
373
+ | `showIconsCheckbox` | `boolean` | `true` | Show checkbox icons in multi-select |
374
+ | `showIconsRadio` | `boolean` | `false` | Show radio icons in single-select |
375
+ | `searchPlaceholder` | `string` | - | Custom search placeholder |
376
+ | `itemIdPropName` | `string` | `"id"` | Property name for item ID |
377
+ | `notifications` | `NotificationsStack` | - | Notification handler for errors |
377
378
 
378
379
  ### Class Props
379
380
 
@@ -422,6 +423,33 @@ A modal-based multi-select/single-select component with search functionality, ty
422
423
  />
423
424
  ```
424
425
 
426
+ ### Ordering the selection (`ordered`)
427
+
428
+ By default the selected items are serialized to `value` in alphabetical order. For
429
+ relations where the order matters, opt in with `ordered` (multi-select only). This adds a
430
+ `Pick | Arrange` tab header inside the modal. The **Arrange** screen shows the current
431
+ selection as a flat list where each row has **Move up / down** (and, on wider screens,
432
+ **Move to top / bottom**) plus **Remove** buttons — buttons only, no drag — and offers
433
+ **Sort A–Z** / **Reverse** shortcuts. The order you set is what gets serialized to `value`
434
+ on submit (and round-trips on reopen). Single-select fields ignore the prop.
435
+
436
+ ```svelte
437
+ <FieldOptions
438
+ label="Steps (in order)"
439
+ name="steps"
440
+ bind:value
441
+ {getOptions}
442
+ cardinality={-1}
443
+ ordered
444
+ />
445
+ ```
446
+
447
+ > Note: with `ordered`, **Select all** appends in the options' (alphabetical) order as a
448
+ > starting point — use the per-row buttons or the Sort/Reverse shortcuts to arrange from
449
+ > there. The `value` must hold full item objects (with their label), which is already the
450
+ > default contract, so the Arrange list can render selected items even when they aren't in
451
+ > the current search results.
452
+
425
453
  ### Customization Examples
426
454
 
427
455
  ```css
@@ -16,6 +16,8 @@
16
16
  label?: SnippetWithId | THC;
17
17
  labelAfter?: SnippetWithId | THC;
18
18
  inputBefore?: SnippetWithId | THC;
19
+ /** Rendered inside the input card, above the input row (e.g. a tab header) */
20
+ inputAbove?: SnippetWithId | THC;
19
21
  children: Snippet;
20
22
  inputAfter?: SnippetWithId | THC;
21
23
  inputBelow?: SnippetWithId | THC;
@@ -40,6 +42,7 @@
40
42
  label,
41
43
  labelAfter,
42
44
  inputBefore,
45
+ inputAbove,
43
46
  children,
44
47
  inputAfter,
45
48
  inputBelow,
@@ -144,6 +147,7 @@
144
147
  invalid && classInputBoxWrapInvalid
145
148
  )}
146
149
  >
150
+ {@render snippetOrThc({ id, value: inputAbove })}
147
151
  <div class="flex">
148
152
  {@render snippetOrThc({ id, value: inputBefore })}
149
153
  {@render children()}
@@ -12,6 +12,8 @@ interface Props extends InputWrapClassProps {
12
12
  label?: SnippetWithId | THC;
13
13
  labelAfter?: SnippetWithId | THC;
14
14
  inputBefore?: SnippetWithId | THC;
15
+ /** Rendered inside the input card, above the input row (e.g. a tab header) */
16
+ inputAbove?: SnippetWithId | THC;
15
17
  children: Snippet;
16
18
  inputAfter?: SnippetWithId | THC;
17
19
  inputBelow?: SnippetWithId | THC;
@@ -17,9 +17,11 @@ export { iconLucideOctagonX as iconAlertError } from "@marianmeres/icons-fns/luc
17
17
  export { iconLucideRefreshCw as iconRefresh } from "@marianmeres/icons-fns/lucide/iconLucideRefreshCw.js";
18
18
  export { iconLucideTriangleAlert as iconAlertWarning } from "@marianmeres/icons-fns/lucide/iconLucideTriangleAlert.js";
19
19
  export { iconLucideArrowDown as iconArrowDown } from "@marianmeres/icons-fns/lucide/iconLucideArrowDown.js";
20
+ export { iconLucideArrowDownToLine as iconArrowDownToLine } from "@marianmeres/icons-fns/lucide/iconLucideArrowDownToLine.js";
20
21
  export { iconLucideArrowLeft as iconArrowLeft } from "@marianmeres/icons-fns/lucide/iconLucideArrowLeft.js";
21
22
  export { iconLucideArrowRight as iconArrowRight } from "@marianmeres/icons-fns/lucide/iconLucideArrowRight.js";
22
23
  export { iconLucideArrowUp as iconArrowUp } from "@marianmeres/icons-fns/lucide/iconLucideArrowUp.js";
24
+ export { iconLucideArrowUpToLine as iconArrowUpToLine } from "@marianmeres/icons-fns/lucide/iconLucideArrowUpToLine.js";
23
25
  export { iconLucideDownload as iconDownload } from "@marianmeres/icons-fns/lucide/iconLucideDownload.js";
24
26
  export { iconLucideMinus as iconMinus } from "@marianmeres/icons-fns/lucide/iconLucideMinus.js";
25
27
  export { iconLucidePlus as iconPlus } from "@marianmeres/icons-fns/lucide/iconLucidePlus.js";
@@ -21,9 +21,11 @@ export { iconLucideRefreshCw as iconRefresh } from "@marianmeres/icons-fns/lucid
21
21
  export { iconLucideTriangleAlert as iconAlertWarning } from "@marianmeres/icons-fns/lucide/iconLucideTriangleAlert.js";
22
22
  // Action Icons (Lucide)
23
23
  export { iconLucideArrowDown as iconArrowDown } from "@marianmeres/icons-fns/lucide/iconLucideArrowDown.js";
24
+ export { iconLucideArrowDownToLine as iconArrowDownToLine } from "@marianmeres/icons-fns/lucide/iconLucideArrowDownToLine.js";
24
25
  export { iconLucideArrowLeft as iconArrowLeft } from "@marianmeres/icons-fns/lucide/iconLucideArrowLeft.js";
25
26
  export { iconLucideArrowRight as iconArrowRight } from "@marianmeres/icons-fns/lucide/iconLucideArrowRight.js";
26
27
  export { iconLucideArrowUp as iconArrowUp } from "@marianmeres/icons-fns/lucide/iconLucideArrowUp.js";
28
+ export { iconLucideArrowUpToLine as iconArrowUpToLine } from "@marianmeres/icons-fns/lucide/iconLucideArrowUpToLine.js";
27
29
  export { iconLucideDownload as iconDownload } from "@marianmeres/icons-fns/lucide/iconLucideDownload.js";
28
30
  export { iconLucideMinus as iconMinus } from "@marianmeres/icons-fns/lucide/iconLucideMinus.js";
29
31
  export { iconLucidePlus as iconPlus } from "@marianmeres/icons-fns/lucide/iconLucidePlus.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.130.0",
3
+ "version": "3.131.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -122,12 +122,12 @@
122
122
  }
123
123
  },
124
124
  "devDependencies": {
125
- "@codemirror/commands": "^6.10.3",
125
+ "@codemirror/commands": "^6.10.4",
126
126
  "@codemirror/lang-markdown": "^6.5.0",
127
127
  "@codemirror/language": "^6.12.3",
128
128
  "@codemirror/language-data": "^6.5.2",
129
- "@codemirror/state": "^6.6.0",
130
- "@codemirror/view": "^6.43.1",
129
+ "@codemirror/state": "^6.7.0",
130
+ "@codemirror/view": "^6.43.2",
131
131
  "@eslint/js": "^9.39.4",
132
132
  "@marianmeres/random-human-readable": "^1.10.2",
133
133
  "@milkdown/core": "^7.21.2",
@@ -140,29 +140,29 @@
140
140
  "@milkdown/transformer": "^7.21.2",
141
141
  "@milkdown/utils": "^7.21.2",
142
142
  "@sveltejs/adapter-auto": "^4.0.0",
143
- "@sveltejs/kit": "^2.65.1",
143
+ "@sveltejs/kit": "^2.68.0",
144
144
  "@sveltejs/package": "^2.5.8",
145
145
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
146
146
  "@tailwindcss/cli": "^4.3.1",
147
147
  "@tailwindcss/forms": "^0.5.11",
148
148
  "@tailwindcss/typography": "^0.5.20",
149
149
  "@tailwindcss/vite": "^4.3.1",
150
- "@types/node": "^25.9.3",
150
+ "@types/node": "^25.9.4",
151
151
  "@vitest/browser-playwright": "^4.1.9",
152
152
  "dotenv": "^16.6.1",
153
153
  "eslint": "^9.39.4",
154
154
  "globals": "^16.5.0",
155
- "playwright": "^1.61.0",
155
+ "playwright": "^1.61.1",
156
156
  "prettier": "^3.8.4",
157
157
  "prettier-plugin-svelte": "^3.5.2",
158
158
  "publint": "^0.3.21",
159
- "svelte": "^5.56.3",
160
- "svelte-check": "^4.6.0",
159
+ "svelte": "^5.56.4",
160
+ "svelte-check": "^4.7.1",
161
161
  "tailwindcss": "^4.3.1",
162
162
  "tsx": "^4.22.4",
163
163
  "typescript": "^5.9.3",
164
- "typescript-eslint": "^8.61.1",
165
- "vite": "^7.3.5",
164
+ "typescript-eslint": "^8.62.0",
165
+ "vite": "^7.3.6",
166
166
  "vitest": "^4.1.9",
167
167
  "vitest-browser-svelte": "^2.1.1"
168
168
  },
@@ -177,7 +177,7 @@
177
177
  "@marianmeres/parse-boolean": "^2.1.0",
178
178
  "@marianmeres/ticker": "^1.17.1",
179
179
  "@marianmeres/tree": "^2.3.0",
180
- "libphonenumber-js": "^1.13.6",
180
+ "libphonenumber-js": "^1.13.7",
181
181
  "runed": "^0.23.4",
182
182
  "tailwind-merge": "^3.6.0"
183
183
  }