@marianmeres/stuic 3.129.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.
package/README.md CHANGED
@@ -195,7 +195,11 @@ When a stuic app is installed and launched standalone (iOS Home Screen, Android/
195
195
 
196
196
  **Automatic:**
197
197
 
198
- - `Notifications` — the toast stack always keeps clear of the insets in standalone (you never want a toast under the home indicator). No prop needed; no-op in a browser tab.
198
+ - `Notifications` — the toast stack always keeps clear of the insets in standalone (you never want a toast under the home indicator).
199
+ - `Modal` — when full-bleed (below the `md` breakpoint, where it fills the screen), its content is padded by the insets so header / content / footer clear the system UI. Centered desktop modals are untouched.
200
+ - `AssetsPreview` — the lightbox image stays edge-to-edge, but the overlay controls (close, prev/next, dots, filename) are offset so they clear the insets.
201
+
202
+ All three are no-ops in a browser tab and need no prop.
199
203
 
200
204
  **Utility classes** — offset any edge-anchored element without writing your own `env()` rule. They are active in standalone only:
201
205
 
@@ -219,7 +223,7 @@ When a stuic app is installed and launched standalone (iOS Home Screen, Android/
219
223
 
220
224
  **Pick ONE layer.** Don't pad the same edge twice in a nesting chain. E.g. a nav `Drawer` whose content is its own stuic `Header`: put `safeArea` on the inner `Header`, not also on the drawer panel/wrapper.
221
225
 
222
- **Not covered:** other fixed/edge-anchored components (`Modal`/`ModalDialog` full-bleed on mobile, `Float`) do not auto-handle insets — apply a `stuic-safe-area-*` class or the variables to their content as needed.
226
+ **Not covered:** remaining fixed/edge-anchored components (e.g. `Float`, or a bare `ModalDialog` used directly) do not auto-handle insets — apply a `stuic-safe-area-*` class or the variables to their content as needed.
223
227
 
224
228
  ## TypeScript
225
229
 
@@ -45,6 +45,7 @@ interface AssetPreview {
45
45
  - **Touch support**: Touch gestures for pan
46
46
  - **Auto-preload**: Preloads full-resolution images when modal opens
47
47
  - **File type icons**: Displays appropriate icons for non-image files
48
+ - **PWA safe area**: when installed/standalone, the overlay controls (close, prev/next, dots, filename) are automatically offset so they clear the status bar / notch / home indicator. The image itself stays edge-to-edge. Inert in a browser tab (`env()` → `0`); no prop required.
48
49
 
49
50
  ## Translation Keys
50
51
 
@@ -658,8 +658,11 @@
658
658
  <div
659
659
  class={twMerge(
660
660
  "absolute inset-0 flex justify-between pointer-events-none",
661
- prevNextBottom ? "items-end pb-4" : "items-center"
661
+ prevNextBottom ? "items-end" : "items-center"
662
662
  )}
663
+ style="padding-left: var(--stuic-safe-area-left, 0px); padding-right: var(--stuic-safe-area-right, 0px);{prevNextBottom
664
+ ? ' padding-bottom: calc(1rem + var(--stuic-safe-area-bottom, 0px));'
665
+ : ''}"
663
666
  >
664
667
  <Button
665
668
  class={twMerge(BUTTON_CLS, "ml-4", classControls)}
@@ -681,7 +684,17 @@
681
684
  </div>
682
685
  {/if}
683
686
 
684
- <div class="absolute top-4 left-4 right-4 flex items-center justify-between gap-3">
687
+ <!-- Top control bar (close, download, zoom, filename). Offset by the PWA
688
+ safe-area insets when installed/standalone so the controls clear the
689
+ status bar / notch / side notch. The vars are 0px in a browser tab. -->
690
+ <div
691
+ class="absolute flex items-center justify-between gap-3"
692
+ style="
693
+ top: calc(1rem + var(--stuic-safe-area-top, 0px));
694
+ left: calc(1rem + var(--stuic-safe-area-left, 0px));
695
+ right: calc(1rem + var(--stuic-safe-area-right, 0px));
696
+ "
697
+ >
685
698
  {#if !noName && previewAsset?.name}
686
699
  <span class="stuic-assets-preview-label truncate px-1">
687
700
  {previewAsset?.name}
@@ -767,7 +780,8 @@
767
780
  {#if !noDots && assets.length <= 10}
768
781
  {#if !noName && dotTooltip}
769
782
  <div
770
- class="absolute bottom-10 left-0 right-0 text-center"
783
+ class="absolute left-0 right-0 text-center"
784
+ style="bottom: calc(2.5rem + var(--stuic-safe-area-bottom, 0px));"
771
785
  transition:fade={{ duration: 100 }}
772
786
  >
773
787
  <span class="stuic-assets-preview-label p-1">
@@ -775,7 +789,10 @@
775
789
  </span>
776
790
  </div>
777
791
  {/if}
778
- <div class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-3">
792
+ <div
793
+ class="absolute left-1/2 -translate-x-1/2 flex items-center gap-3"
794
+ style="bottom: calc(1rem + var(--stuic-safe-area-bottom, 0px));"
795
+ >
779
796
  {#each assets as _, i}
780
797
  <!-- svelte-ignore a11y_mouse_events_have_key_events -->
781
798
  <button
@@ -793,7 +810,10 @@
793
810
  {/each}
794
811
  </div>
795
812
  {:else if !noCurrentOfTotal}
796
- <div class="absolute bottom-4 left-1/2 -translate-x-1/2">
813
+ <div
814
+ class="absolute left-1/2 -translate-x-1/2"
815
+ style="bottom: calc(1rem + var(--stuic-safe-area-bottom, 0px));"
816
+ >
797
817
  <span class="stuic-assets-preview-label px-2 py-1 text-sm opacity-60">
798
818
  {previewIdx + 1} / {assets.length}
799
819
  </span>
@@ -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;
@@ -107,6 +107,10 @@ By default, Modal is:
107
107
  - **Mobile**: Full screen with 1rem margins from viewport edges
108
108
  - **Desktop (md+)**: Centered, max-width 768px, auto height with max 80vh
109
109
 
110
+ ### PWA safe area (automatic)
111
+
112
+ When the app runs installed/standalone (display-mode standalone or fullscreen) and the Modal is **full-bleed** (below the `md` breakpoint), its content is automatically padded by the device safe-area insets so the header / content / footer clear the status bar, notch and home indicator. This is **inert in a normal browser tab** (`env()` → `0`) and on **centered desktop** modals (which don't touch the edges). No prop required. To go truly edge-to-edge, override the padding on the inner panel via `class`.
113
+
110
114
  ## CSS Variables
111
115
 
112
116
  Override to customize appearance:
@@ -27,4 +27,24 @@
27
27
  .stuic-modal .main {
28
28
  scrollbar-width: thin;
29
29
  }
30
+
31
+ /* ============================================================================
32
+ SAFE AREA (PWA installed / standalone) — automatic, full-bleed only.
33
+ The Modal is full-screen below the `md` breakpoint (size-full) and a
34
+ centered card at/above it (md:size-auto). Only the full-bleed case touches
35
+ the device edges, so we gate on `width < 48rem` (Tailwind's md) AND
36
+ standalone, and pad the bg-bearing .stuic-modal (a flex column) so its
37
+ header / content / footer clear the status bar, notch and home indicator.
38
+ Centered desktop modals and browser tabs (env → 0) are untouched. A
39
+ consumer wanting true edge-to-edge can override padding via `class`.
40
+ ============================================================================ */
41
+ @media (display-mode: standalone) and (width < 48rem),
42
+ (display-mode: fullscreen) and (width < 48rem) {
43
+ .stuic-modal {
44
+ padding-top: env(safe-area-inset-top);
45
+ padding-right: env(safe-area-inset-right);
46
+ padding-bottom: env(safe-area-inset-bottom);
47
+ padding-left: env(safe-area-inset-left);
48
+ }
49
+ }
30
50
  }
@@ -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.129.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
  }