@marianmeres/stuic 3.130.0 → 3.132.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/dist/components/Input/FieldAssets.svelte +116 -3
- package/dist/components/Input/FieldAssets.svelte.d.ts +7 -0
- package/dist/components/Input/FieldOptions.svelte +579 -178
- package/dist/components/Input/FieldOptions.svelte.d.ts +7 -0
- package/dist/components/Input/README.md +70 -15
- package/dist/components/Input/_internal/InputWrap.svelte +4 -0
- package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -0
- package/dist/icons/index.d.ts +2 -0
- package/dist/icons/index.js +2 -0
- package/package.json +12 -12
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
iconFileWord,
|
|
15
15
|
iconFileZip,
|
|
16
16
|
iconPlus as iconAdd,
|
|
17
|
+
iconChevronLeft,
|
|
18
|
+
iconChevronRight,
|
|
17
19
|
} from "../../icons/index.js";
|
|
18
|
-
import { onDestroy, type Snippet } from "svelte";
|
|
20
|
+
import { onDestroy, tick, type Snippet } from "svelte";
|
|
19
21
|
import { fileDropzone } from "../../actions/file-dropzone.svelte.js";
|
|
20
22
|
import { highlightDragover } from "../../actions/highlight-dragover.svelte.js";
|
|
21
23
|
import { tooltip } from "../../actions/index.js";
|
|
@@ -58,6 +60,10 @@
|
|
|
58
60
|
close: "Close preview window",
|
|
59
61
|
download: "Download original",
|
|
60
62
|
invalid_type: 'Some of the files are not supported. Allowed are only "{{accept}}".',
|
|
63
|
+
move_prev: "Move earlier",
|
|
64
|
+
move_next: "Move later",
|
|
65
|
+
moved_prev: "Moved {{name}} earlier",
|
|
66
|
+
moved_next: "Moved {{name}} later",
|
|
61
67
|
};
|
|
62
68
|
let out = m[k] ?? fallback ?? k;
|
|
63
69
|
|
|
@@ -167,6 +173,13 @@
|
|
|
167
173
|
withOnProgress?: boolean;
|
|
168
174
|
classControls?: string;
|
|
169
175
|
isLoading?: boolean;
|
|
176
|
+
/**
|
|
177
|
+
* Opt-in: when true, each asset tile in the grid gets "Move earlier" / "Move later"
|
|
178
|
+
* controls (shown on hover/focus) so the user can manually reorder the assets. The
|
|
179
|
+
* chosen order is serialized to `value`. Buttons only, no drag (the field's drag is
|
|
180
|
+
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
181
|
+
*/
|
|
182
|
+
ordered?: boolean;
|
|
170
183
|
//
|
|
171
184
|
classWrap?: string;
|
|
172
185
|
}
|
|
@@ -231,6 +244,7 @@
|
|
|
231
244
|
withOnProgress,
|
|
232
245
|
classControls = "",
|
|
233
246
|
isLoading = false,
|
|
247
|
+
ordered = false,
|
|
234
248
|
parseValue = (strigifiedModels: string) => {
|
|
235
249
|
const val = strigifiedModels ?? "[]";
|
|
236
250
|
try {
|
|
@@ -249,6 +263,10 @@
|
|
|
249
263
|
let isUploading = $state(false);
|
|
250
264
|
let cardinality = $derived(_cardinality === -1 ? Infinity : _cardinality);
|
|
251
265
|
let isMultiple = $derived(cardinality > 1);
|
|
266
|
+
// reordering ("ordered") is opt-in and only meaningful with more than one asset
|
|
267
|
+
let canArrange = $derived(ordered && isMultiple);
|
|
268
|
+
// aria-live announcement text for reorder actions
|
|
269
|
+
let liveAnnouncement = $state("");
|
|
252
270
|
let parentHiddenInputEl: HTMLInputElement | undefined = $state();
|
|
253
271
|
let hasLabel = $derived(isTHCNotEmpty(label) || typeof label === "function");
|
|
254
272
|
let inputEl = $state<HTMLInputElement>()!;
|
|
@@ -348,6 +366,44 @@
|
|
|
348
366
|
notifications?.info(t("deleted", { name }));
|
|
349
367
|
}
|
|
350
368
|
|
|
369
|
+
// re-focus the same logical move button on the tile that moved, so repeated presses keep
|
|
370
|
+
// walking the item; if that button is now disabled (a boundary), fall back to the enabled one.
|
|
371
|
+
// We target by the NEW index (querySelectorAll DOM order == array order) to avoid escaping
|
|
372
|
+
// blob/url ids into a CSS attribute selector.
|
|
373
|
+
function focusMovedButton(to: number, which: "prev" | "next") {
|
|
374
|
+
tick().then(() => {
|
|
375
|
+
const tile = wrapEl?.querySelectorAll<HTMLElement>("[data-asset-tile]")[to];
|
|
376
|
+
if (!tile) return;
|
|
377
|
+
let btn = tile.querySelector<HTMLButtonElement>(`[data-arrange-btn="${which}"]`);
|
|
378
|
+
if (!btn || btn.disabled) {
|
|
379
|
+
btn =
|
|
380
|
+
tile.querySelector<HTMLButtonElement>(
|
|
381
|
+
`[data-arrange-btn="prev"]:not([disabled])`
|
|
382
|
+
) ||
|
|
383
|
+
tile.querySelector<HTMLButtonElement>(
|
|
384
|
+
`[data-arrange-btn="next"]:not([disabled])`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
btn?.focus();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Reorder by moving the asset at `from` to `to`. Splices a copy and re-serializes (same
|
|
392
|
+
// value-driven pattern as remove_by_idx). Safe mid-upload: processAssets reconciles the
|
|
393
|
+
// resolved asset by blob-url id (findIndex), never by array position.
|
|
394
|
+
function move(from: number, to: number) {
|
|
395
|
+
if (to < 0 || to >= assets.length || from === to) return;
|
|
396
|
+
const next = [...assets];
|
|
397
|
+
const [item] = next.splice(from, 1);
|
|
398
|
+
if (!item) return;
|
|
399
|
+
next.splice(to, 0, item);
|
|
400
|
+
value = serializeValue(next);
|
|
401
|
+
liveAnnouncement = t(to < from ? "moved_prev" : "moved_next", {
|
|
402
|
+
name: item.name,
|
|
403
|
+
}) as string;
|
|
404
|
+
focusMovedButton(to, to < from ? "prev" : "next");
|
|
405
|
+
}
|
|
406
|
+
|
|
351
407
|
onDestroy(() => {
|
|
352
408
|
try {
|
|
353
409
|
assets.forEach((a) => {
|
|
@@ -369,11 +425,15 @@
|
|
|
369
425
|
<SpinnerCircleOscillate />
|
|
370
426
|
</div>
|
|
371
427
|
{:else}
|
|
428
|
+
{#if canArrange}
|
|
429
|
+
<!-- screen-reader announcements for reorder actions -->
|
|
430
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
|
|
431
|
+
{/if}
|
|
372
432
|
<div class={["p-2 flex items-center gap-0.5 flex-wrap"]}>
|
|
373
|
-
{#each assets as asset, idx}
|
|
433
|
+
{#each assets as asset, idx (asset.id)}
|
|
374
434
|
{@const { thumb, full, original } = asset_urls(asset)}
|
|
375
435
|
{@const _is_img = isImage(asset.type ?? thumb)}
|
|
376
|
-
<div class="relative group">
|
|
436
|
+
<div class="relative group" data-asset-tile>
|
|
377
437
|
<button
|
|
378
438
|
class={[objectSize, "bg-black/10 grid place-content-center", classControls]}
|
|
379
439
|
onclick={(e) => {
|
|
@@ -420,6 +480,59 @@
|
|
|
420
480
|
</span>
|
|
421
481
|
{/if}
|
|
422
482
|
</button>
|
|
483
|
+
|
|
484
|
+
{#if canArrange && assets.length > 1}
|
|
485
|
+
<!-- Reorder controls (sibling of the thumbnail <button>, never nested — that
|
|
486
|
+
would be invalid HTML). Hidden until the tile is hovered/focused; pointer
|
|
487
|
+
events are gated to hover/focus so an invisible control can't intercept a
|
|
488
|
+
click meant for the thumbnail, while keyboard users can still Tab to them. -->
|
|
489
|
+
<div
|
|
490
|
+
class={[
|
|
491
|
+
"absolute inset-x-0 top-0 flex items-start justify-between p-0.5",
|
|
492
|
+
"opacity-0 transition-opacity pointer-events-none",
|
|
493
|
+
"group-hover:opacity-100 focus-within:opacity-100",
|
|
494
|
+
]}
|
|
495
|
+
>
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
data-arrange-btn="prev"
|
|
499
|
+
disabled={idx === 0}
|
|
500
|
+
aria-label={t("move_prev")}
|
|
501
|
+
use:tooltip={() => ({ content: t("move_prev") })}
|
|
502
|
+
class={[
|
|
503
|
+
"grid place-content-center rounded p-0.5 bg-black/60 text-white hover:bg-black/80",
|
|
504
|
+
"pointer-events-none group-hover:pointer-events-auto focus:pointer-events-auto",
|
|
505
|
+
"disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-black/60",
|
|
506
|
+
]}
|
|
507
|
+
onclick={(e) => {
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
e.preventDefault();
|
|
510
|
+
move(idx, idx - 1);
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
{@html iconChevronLeft({ size: 16 })}
|
|
514
|
+
</button>
|
|
515
|
+
<button
|
|
516
|
+
type="button"
|
|
517
|
+
data-arrange-btn="next"
|
|
518
|
+
disabled={idx === assets.length - 1}
|
|
519
|
+
aria-label={t("move_next")}
|
|
520
|
+
use:tooltip={() => ({ content: t("move_next") })}
|
|
521
|
+
class={[
|
|
522
|
+
"grid place-content-center rounded p-0.5 bg-black/60 text-white hover:bg-black/80",
|
|
523
|
+
"pointer-events-none group-hover:pointer-events-auto focus:pointer-events-auto",
|
|
524
|
+
"disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-black/60",
|
|
525
|
+
]}
|
|
526
|
+
onclick={(e) => {
|
|
527
|
+
e.stopPropagation();
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
move(idx, idx + 1);
|
|
530
|
+
}}
|
|
531
|
+
>
|
|
532
|
+
{@html iconChevronRight({ size: 16 })}
|
|
533
|
+
</button>
|
|
534
|
+
</div>
|
|
535
|
+
{/if}
|
|
423
536
|
</div>
|
|
424
537
|
{/each}
|
|
425
538
|
<Button
|
|
@@ -61,6 +61,13 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
61
61
|
withOnProgress?: boolean;
|
|
62
62
|
classControls?: string;
|
|
63
63
|
isLoading?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Opt-in: when true, each asset tile in the grid gets "Move earlier" / "Move later"
|
|
66
|
+
* controls (shown on hover/focus) so the user can manually reorder the assets. The
|
|
67
|
+
* chosen order is serialized to `value`. Buttons only, no drag (the field's drag is
|
|
68
|
+
* reserved for file drops); full keyboard + aria-live announcements. Default `false`.
|
|
69
|
+
*/
|
|
70
|
+
ordered?: boolean;
|
|
64
71
|
classWrap?: string;
|
|
65
72
|
}
|
|
66
73
|
declare const FieldAssets: import("svelte").Component<Props, {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
2
|
import { createClog } from "@marianmeres/clog";
|
|
3
|
-
import {
|
|
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" / "Shuffle" 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,30 @@
|
|
|
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
|
+
shuffle: "Shuffle",
|
|
134
|
+
move_up: "Move up",
|
|
135
|
+
move_down: "Move down",
|
|
136
|
+
move_to_top: "Move to top",
|
|
137
|
+
move_to_bottom: "Move to bottom",
|
|
138
|
+
remove_item: "Remove",
|
|
139
|
+
moved_up: "Moved {{value}} up",
|
|
140
|
+
moved_down: "Moved {{value}} down",
|
|
141
|
+
removed_item: "Removed {{value}}",
|
|
142
|
+
sorted_az: "Sorted A to Z",
|
|
143
|
+
reversed: "Order reversed",
|
|
144
|
+
shuffled: "Order shuffled",
|
|
109
145
|
};
|
|
110
146
|
let out = m[k] ?? fallback ?? k;
|
|
111
147
|
|
|
@@ -177,6 +213,7 @@
|
|
|
177
213
|
renderOptionLabel,
|
|
178
214
|
renderOptionGroup = (s: string) => `${s}`.replaceAll("_", " "),
|
|
179
215
|
allowUnknown = false,
|
|
216
|
+
ordered = false,
|
|
180
217
|
showIconsCheckbox = true,
|
|
181
218
|
showIconsRadio = false,
|
|
182
219
|
searchPlaceholder,
|
|
@@ -229,6 +266,21 @@
|
|
|
229
266
|
let isMultiple = $derived(cardinality > 1);
|
|
230
267
|
let showIcons = $derived(isMultiple ? showIconsCheckbox : showIconsRadio);
|
|
231
268
|
|
|
269
|
+
// the "Arrange" (manual ordering) feature is opt-in and only meaningful for multi-select
|
|
270
|
+
let canArrange = $derived(ordered && isMultiple);
|
|
271
|
+
// false = "Pick" screen (search + options), true = "Arrange" screen (reorder selection)
|
|
272
|
+
let arrangeMode = $state(false);
|
|
273
|
+
// aria-live announcement text for reorder/remove actions
|
|
274
|
+
let liveAnnouncement = $state("");
|
|
275
|
+
// the arrange list scroller (for focus management)
|
|
276
|
+
let arrangeListBox: HTMLDivElement | undefined = $state();
|
|
277
|
+
// hydrate-once tracker (ordered mode): the `value` we last loaded the selection from
|
|
278
|
+
let hydratedValue: string | undefined = undefined;
|
|
279
|
+
// keep arrange state clean if the feature gets disabled at runtime (e.g. cardinality -> 1)
|
|
280
|
+
$effect(() => {
|
|
281
|
+
if (!canArrange && arrangeMode) arrangeMode = false;
|
|
282
|
+
});
|
|
283
|
+
|
|
232
284
|
//
|
|
233
285
|
let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
|
|
234
286
|
enabled: true,
|
|
@@ -294,7 +346,13 @@
|
|
|
294
346
|
|
|
295
347
|
// reconfigure if the prop ever changes during runtime (most likely will NOT)
|
|
296
348
|
$effect(() => {
|
|
297
|
-
|
|
349
|
+
// In "arrange" mode we UNSET the sortFn (item-collection: `null` unsets, a function
|
|
350
|
+
// keeps it) so the selection keeps its manual/insertion order instead of being
|
|
351
|
+
// auto-sorted alphabetically. Default mode keeps the alpha sortFn => byte-identical.
|
|
352
|
+
_selectedColl.configure({
|
|
353
|
+
cardinality,
|
|
354
|
+
sortFn: canArrange ? (null as any) : sortFn,
|
|
355
|
+
});
|
|
298
356
|
// trim excess selections if cardinality was reduced
|
|
299
357
|
if (_selectedColl.size > cardinality) {
|
|
300
358
|
const trimmed = _selectedColl.items.slice(0, cardinality);
|
|
@@ -356,15 +414,35 @@
|
|
|
356
414
|
}
|
|
357
415
|
);
|
|
358
416
|
|
|
417
|
+
function _hydrateSelectedFromValue(v: string) {
|
|
418
|
+
_selectedColl.clear().addMany(maybeJsonParse(v) as Item[]);
|
|
419
|
+
// IMPORTANT: focus first selected so it scrolls into view on open
|
|
420
|
+
if (_selectedColl.size) {
|
|
421
|
+
waitForNextRepaint().then(() => {
|
|
422
|
+
if (!isUnmounted) _optionsColl.setActive(_selectedColl.items[0]);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
359
427
|
$effect(() => {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
428
|
+
const visible = modalDialog.visibility().visible;
|
|
429
|
+
if (canArrange) {
|
|
430
|
+
// ORDERED MODE: hydrate the selection from `value` ONLY on open / external value
|
|
431
|
+
// change — deliberately NOT on every fetch (`touch`), otherwise a manual reorder
|
|
432
|
+
// (or any not-yet-submitted pick) would be silently reset on the next keystroke.
|
|
433
|
+
const v = value;
|
|
434
|
+
if (!visible) {
|
|
435
|
+
hydratedValue = undefined;
|
|
436
|
+
return;
|
|
367
437
|
}
|
|
438
|
+
if (hydratedValue === v) return;
|
|
439
|
+
hydratedValue = v;
|
|
440
|
+
// ensure the sortFn is unset before addMany, so the saved order survives the load
|
|
441
|
+
_selectedColl.configure({ sortFn: null as any }, false);
|
|
442
|
+
_hydrateSelectedFromValue(v);
|
|
443
|
+
} else {
|
|
444
|
+
// DEFAULT MODE: unchanged behavior (re-sync to `value` whenever visible & touched)
|
|
445
|
+
if (visible && touch) _hydrateSelectedFromValue(value);
|
|
368
446
|
}
|
|
369
447
|
});
|
|
370
448
|
|
|
@@ -384,6 +462,98 @@
|
|
|
384
462
|
return prefix + strHash(`${id}`.repeat(3));
|
|
385
463
|
}
|
|
386
464
|
|
|
465
|
+
// --- Arrange (ordered) screen helpers ---
|
|
466
|
+
|
|
467
|
+
function arrange_row_id(id: string | number) {
|
|
468
|
+
return btn_id(id, "arr-");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function _announce(key: string, item: Item) {
|
|
472
|
+
liveAnnouncement = t(key, { value: _renderOptionLabel(item) }) as string;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// re-focus the same logical button on the row that moved, so repeated presses keep
|
|
476
|
+
// walking the item; if that button is now disabled (a boundary), fall back to an enabled one
|
|
477
|
+
function focusRowButton(itemId: string | number, which: "up" | "down") {
|
|
478
|
+
tick().then(() => {
|
|
479
|
+
const row = arrangeListBox?.querySelector(`#${arrange_row_id(itemId)}`);
|
|
480
|
+
if (!row) return;
|
|
481
|
+
let btn = row.querySelector<HTMLButtonElement>(`[data-arrange-btn="${which}"]`);
|
|
482
|
+
if (!btn || btn.disabled) {
|
|
483
|
+
btn =
|
|
484
|
+
row.querySelector<HTMLButtonElement>(
|
|
485
|
+
`[data-arrange-btn="up"]:not([disabled])`
|
|
486
|
+
) ||
|
|
487
|
+
row.querySelector<HTMLButtonElement>(
|
|
488
|
+
`[data-arrange-btn="down"]:not([disabled])`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
btn?.focus();
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function moveItem(from: number, to: number) {
|
|
496
|
+
if (to < 0 || to >= _selectedColl.size || from === to) return;
|
|
497
|
+
const item = _selectedColl.items[from];
|
|
498
|
+
if (!_selectedColl.move(from, to)) return;
|
|
499
|
+
if (item) {
|
|
500
|
+
_announce(to < from ? "moved_up" : "moved_down", item);
|
|
501
|
+
focusRowButton(item[itemIdPropName], to < from ? "up" : "down");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function removeItem(i: number) {
|
|
506
|
+
const item = _selectedColl.items[i];
|
|
507
|
+
_selectedColl.removeAt(i);
|
|
508
|
+
if (item) _announce("removed_item", item);
|
|
509
|
+
tick().then(() => {
|
|
510
|
+
if (!_selectedColl.size) return enterPick();
|
|
511
|
+
const next = _selectedColl.items[Math.min(i, _selectedColl.size - 1)];
|
|
512
|
+
if (next) focusRowButton(next[itemIdPropName], "down");
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// one-shot sort by visible label (does NOT re-enable auto-sort; subsequent adds still append)
|
|
517
|
+
function sortAZ() {
|
|
518
|
+
_selectedColl.sort((a, b) =>
|
|
519
|
+
_renderOptionLabel(a).localeCompare(_renderOptionLabel(b), undefined, {
|
|
520
|
+
sensitivity: "base",
|
|
521
|
+
})
|
|
522
|
+
);
|
|
523
|
+
liveAnnouncement = t("sorted_az") as string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// no reverse() on the collection; snapshot, clear (no publish), re-add in reversed order
|
|
527
|
+
function reverse() {
|
|
528
|
+
const reversed = [...selected.items].reverse();
|
|
529
|
+
_selectedColl.clear(false).addMany(reversed);
|
|
530
|
+
liveAnnouncement = t("reversed") as string;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// random reorder (Fisher–Yates); same snapshot/clear/re-add dance as reverse()
|
|
534
|
+
function shuffle() {
|
|
535
|
+
const arr = [...selected.items];
|
|
536
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
537
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
538
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
539
|
+
}
|
|
540
|
+
_selectedColl.clear(false).addMany(arr);
|
|
541
|
+
liveAnnouncement = t("shuffled") as string;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function enterArrange() {
|
|
545
|
+
if (!selected.items.length) return;
|
|
546
|
+
arrangeMode = true;
|
|
547
|
+
input?.blur(); // dismiss the soft keyboard (no text input on this screen)
|
|
548
|
+
const first = selected.items[0];
|
|
549
|
+
if (first) focusRowButton(first[itemIdPropName], "down");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function enterPick() {
|
|
553
|
+
arrangeMode = false;
|
|
554
|
+
tick().then(() => input?.focus());
|
|
555
|
+
}
|
|
556
|
+
|
|
387
557
|
// "inner" submit
|
|
388
558
|
function try_submit(force = false) {
|
|
389
559
|
// clog("try_submit", innerValue, _selectedColl.dump());
|
|
@@ -440,6 +610,7 @@
|
|
|
440
610
|
// clog("modal submit", $state.snapshot(selected.items));
|
|
441
611
|
value = JSON.stringify(selected.items);
|
|
442
612
|
innerValue = "";
|
|
613
|
+
arrangeMode = false;
|
|
443
614
|
_optionsColl.clear();
|
|
444
615
|
modalDialog.close();
|
|
445
616
|
_dispatch_change_to_owner();
|
|
@@ -449,6 +620,7 @@
|
|
|
449
620
|
// clears state and dispatches change; close is handled by ModalDialog's preEscapeClose
|
|
450
621
|
function escape() {
|
|
451
622
|
innerValue = "";
|
|
623
|
+
arrangeMode = false;
|
|
452
624
|
_optionsColl.clear();
|
|
453
625
|
_dispatch_change_to_owner();
|
|
454
626
|
}
|
|
@@ -506,7 +678,9 @@
|
|
|
506
678
|
<!-- this must be on window as we're catching any typing anywhere -->
|
|
507
679
|
<svelte:window
|
|
508
680
|
onkeydown={(e) => {
|
|
509
|
-
|
|
681
|
+
// on the Arrange screen the reorder buttons own the keyboard; the option-list
|
|
682
|
+
// navigation + "any key focuses search" behavior must NOT run there
|
|
683
|
+
if (modalDialog.visibility().visible && !arrangeMode) {
|
|
510
684
|
// arrow navigation
|
|
511
685
|
if (["ArrowDown", "ArrowUp"].includes(e.key)) {
|
|
512
686
|
e.preventDefault();
|
|
@@ -602,6 +776,11 @@
|
|
|
602
776
|
>
|
|
603
777
|
<div class="pt-0 md:pt-[20vh] w-full">
|
|
604
778
|
<div class="pointer-events-auto">
|
|
779
|
+
<!-- screen-reader announcements for reorder / remove actions -->
|
|
780
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">
|
|
781
|
+
{liveAnnouncement}
|
|
782
|
+
</div>
|
|
783
|
+
|
|
605
784
|
<InputWrap
|
|
606
785
|
size={renderSize}
|
|
607
786
|
class={twMerge("m-1 sm:m-2 mb-12 shadow-xl", classModalField)}
|
|
@@ -613,12 +792,75 @@
|
|
|
613
792
|
{id}
|
|
614
793
|
{required}
|
|
615
794
|
>
|
|
795
|
+
{#snippet inputAbove()}
|
|
796
|
+
{#if canArrange}
|
|
797
|
+
<!-- Pick | Arrange tab header — lives INSIDE the card (solid bg) -->
|
|
798
|
+
<div
|
|
799
|
+
class="relative flex items-stretch px-2 border-b"
|
|
800
|
+
style={`border-color: var(--stuic-field-options-divider); min-height: var(--stuic-input-min-height-${renderSize}, 3rem);`}
|
|
801
|
+
>
|
|
802
|
+
<!-- full-height tabs => titles are vertically centered in the row -->
|
|
803
|
+
<div role="tablist" class="flex items-stretch gap-1">
|
|
804
|
+
<button
|
|
805
|
+
type="button"
|
|
806
|
+
role="tab"
|
|
807
|
+
aria-selected={!arrangeMode}
|
|
808
|
+
onclick={enterPick}
|
|
809
|
+
class={twMerge(
|
|
810
|
+
// keep font-weight constant across states so the tab doesn't reflow
|
|
811
|
+
"flex items-center px-3 -mb-px text-sm font-semibold border-b-2",
|
|
812
|
+
!arrangeMode
|
|
813
|
+
? "border-(--stuic-input-accent) text-(--stuic-input-accent)"
|
|
814
|
+
: "border-transparent stuic-field-options-muted"
|
|
815
|
+
)}
|
|
816
|
+
>
|
|
817
|
+
{@html t("pick_tab")}
|
|
818
|
+
</button>
|
|
819
|
+
<button
|
|
820
|
+
type="button"
|
|
821
|
+
role="tab"
|
|
822
|
+
aria-selected={arrangeMode}
|
|
823
|
+
disabled={!selected.items.length}
|
|
824
|
+
onclick={enterArrange}
|
|
825
|
+
class={twMerge(
|
|
826
|
+
// keep font-weight constant across states so the tab doesn't reflow
|
|
827
|
+
"flex items-center px-3 -mb-px text-sm font-semibold border-b-2",
|
|
828
|
+
arrangeMode
|
|
829
|
+
? "border-(--stuic-input-accent) text-(--stuic-input-accent)"
|
|
830
|
+
: "border-transparent stuic-field-options-muted"
|
|
831
|
+
)}
|
|
832
|
+
>
|
|
833
|
+
{@html t("arrange_tab", { value: selected.items.length })}
|
|
834
|
+
</button>
|
|
835
|
+
</div>
|
|
836
|
+
|
|
837
|
+
<!-- modal close lives in the tab header whenever tabs exist (both screens).
|
|
838
|
+
Absolutely positioned + height-matched row, so it never causes a jump. -->
|
|
839
|
+
<div class="absolute inset-y-0 right-1 flex items-center">
|
|
840
|
+
<Button
|
|
841
|
+
x
|
|
842
|
+
size="sm"
|
|
843
|
+
variant="ghost"
|
|
844
|
+
roundedFull
|
|
845
|
+
type="button"
|
|
846
|
+
tooltip={t("close")}
|
|
847
|
+
onclick={(e) => {
|
|
848
|
+
e.preventDefault();
|
|
849
|
+
escape();
|
|
850
|
+
modalDialog.close();
|
|
851
|
+
}}
|
|
852
|
+
/>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
{/if}
|
|
856
|
+
{/snippet}
|
|
857
|
+
|
|
616
858
|
<input
|
|
617
859
|
bind:value={innerValue}
|
|
618
860
|
bind:this={input}
|
|
619
861
|
{type}
|
|
620
862
|
{id}
|
|
621
|
-
class={twMerge(renderSize, classInput)}
|
|
863
|
+
class={twMerge(renderSize, classInput, arrangeMode && "hidden")}
|
|
622
864
|
tabindex={1}
|
|
623
865
|
{required}
|
|
624
866
|
{disabled}
|
|
@@ -638,203 +880,362 @@
|
|
|
638
880
|
|
|
639
881
|
{#snippet inputBelow()}
|
|
640
882
|
<div
|
|
641
|
-
class="h-full
|
|
883
|
+
class={twMerge("h-full p-2", !arrangeMode && "border-t")}
|
|
642
884
|
style="border-color: var(--stuic-field-options-divider);"
|
|
643
885
|
>
|
|
644
|
-
|
|
645
|
-
|
|
886
|
+
{#snippet footerSubmitBtn()}
|
|
887
|
+
<div>
|
|
888
|
+
<Button
|
|
889
|
+
class="control"
|
|
890
|
+
type="button"
|
|
891
|
+
intent="primary"
|
|
892
|
+
onclick={async (e) => {
|
|
893
|
+
e.preventDefault();
|
|
894
|
+
try_submit(true);
|
|
895
|
+
}}
|
|
896
|
+
tabindex={3}
|
|
897
|
+
>
|
|
898
|
+
{@html t("submit")}
|
|
899
|
+
</Button>
|
|
900
|
+
</div>
|
|
901
|
+
{/snippet}
|
|
902
|
+
|
|
903
|
+
{#if canArrange && arrangeMode}
|
|
904
|
+
<!-- ARRANGE screen: reorder the current selection (buttons only, no drag) -->
|
|
905
|
+
<div class="text-sm -mt-1 flex items-center">
|
|
646
906
|
<button
|
|
647
907
|
type="button"
|
|
648
|
-
onclick={
|
|
908
|
+
onclick={sortAZ}
|
|
909
|
+
disabled={selected.items.length < 2}
|
|
649
910
|
class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
|
|
650
911
|
tabindex={4}
|
|
651
|
-
disabled={!options.size}
|
|
652
912
|
>
|
|
653
|
-
{@html t("
|
|
913
|
+
{@html t("sort_az")}
|
|
654
914
|
</button>
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
915
|
+
<button
|
|
916
|
+
type="button"
|
|
917
|
+
onclick={reverse}
|
|
918
|
+
disabled={selected.items.length < 2}
|
|
919
|
+
class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
|
|
920
|
+
tabindex={5}
|
|
921
|
+
>
|
|
922
|
+
{@html t("reverse")}
|
|
923
|
+
</button>
|
|
924
|
+
<button
|
|
925
|
+
type="button"
|
|
926
|
+
onclick={shuffle}
|
|
927
|
+
disabled={selected.items.length < 2}
|
|
928
|
+
class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
|
|
929
|
+
tabindex={6}
|
|
930
|
+
>
|
|
931
|
+
{@html t("shuffle")}
|
|
932
|
+
</button>
|
|
933
|
+
<span
|
|
934
|
+
class="flex-1 block justify-end text-right text-xs p-1 pr-2 stuic-field-options-muted"
|
|
935
|
+
>
|
|
936
|
+
{selected.items.length}
|
|
937
|
+
{#if cardinality > 0 && cardinality < Infinity}
|
|
938
|
+
{@html t("cardinality_of")} {cardinality}
|
|
939
|
+
{/if}
|
|
940
|
+
{@html t("cardinality_selected")}
|
|
941
|
+
</span>
|
|
942
|
+
</div>
|
|
669
943
|
|
|
670
|
-
<
|
|
671
|
-
|
|
672
|
-
|
|
944
|
+
<div
|
|
945
|
+
class={[
|
|
946
|
+
"options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
|
|
947
|
+
"h-55 max-h-55",
|
|
948
|
+
]}
|
|
949
|
+
bind:this={arrangeListBox}
|
|
950
|
+
tabindex="-1"
|
|
673
951
|
>
|
|
674
|
-
{selected.items
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
952
|
+
{#each selected.items as item, i (item[itemIdPropName])}
|
|
953
|
+
{@const last = selected.items.length - 1}
|
|
954
|
+
<div
|
|
955
|
+
id={arrange_row_id(item[itemIdPropName])}
|
|
956
|
+
class="flex items-center gap-1 px-1 py-0.5"
|
|
957
|
+
>
|
|
958
|
+
<span class="flex-1 min-w-0 truncate text-sm"
|
|
959
|
+
>{_renderOptionLabel(item)}</span
|
|
960
|
+
>
|
|
961
|
+
<span class="hidden sm:inline-flex">
|
|
962
|
+
<Button
|
|
963
|
+
iconButton
|
|
964
|
+
variant="ghost"
|
|
965
|
+
type="button"
|
|
966
|
+
aria-label={t("move_to_top")}
|
|
967
|
+
tooltip={t("move_to_top")}
|
|
968
|
+
data-arrange-btn="top"
|
|
969
|
+
disabled={i === 0}
|
|
970
|
+
onclick={() => moveItem(i, 0)}
|
|
971
|
+
>
|
|
972
|
+
{@html iconArrowUpToLine({ size: 18 })}
|
|
973
|
+
</Button>
|
|
974
|
+
</span>
|
|
975
|
+
<Button
|
|
976
|
+
iconButton
|
|
977
|
+
variant="ghost"
|
|
978
|
+
type="button"
|
|
979
|
+
aria-label={t("move_up")}
|
|
980
|
+
tooltip={t("move_up")}
|
|
981
|
+
data-arrange-btn="up"
|
|
982
|
+
disabled={i === 0}
|
|
983
|
+
onclick={() => moveItem(i, i - 1)}
|
|
984
|
+
>
|
|
985
|
+
{@html iconChevronUp({ size: 18 })}
|
|
986
|
+
</Button>
|
|
987
|
+
<Button
|
|
988
|
+
iconButton
|
|
989
|
+
variant="ghost"
|
|
990
|
+
type="button"
|
|
991
|
+
aria-label={t("move_down")}
|
|
992
|
+
tooltip={t("move_down")}
|
|
993
|
+
data-arrange-btn="down"
|
|
994
|
+
disabled={i === last}
|
|
995
|
+
onclick={() => moveItem(i, i + 1)}
|
|
996
|
+
>
|
|
997
|
+
{@html iconChevronDown({ size: 18 })}
|
|
998
|
+
</Button>
|
|
999
|
+
<span class="hidden sm:inline-flex">
|
|
1000
|
+
<Button
|
|
1001
|
+
iconButton
|
|
1002
|
+
variant="ghost"
|
|
1003
|
+
type="button"
|
|
1004
|
+
aria-label={t("move_to_bottom")}
|
|
1005
|
+
tooltip={t("move_to_bottom")}
|
|
1006
|
+
data-arrange-btn="bottom"
|
|
1007
|
+
disabled={i === last}
|
|
1008
|
+
onclick={() => moveItem(i, last)}
|
|
1009
|
+
>
|
|
1010
|
+
{@html iconArrowDownToLine({ size: 18 })}
|
|
1011
|
+
</Button>
|
|
1012
|
+
</span>
|
|
1013
|
+
<Button
|
|
1014
|
+
iconButton
|
|
1015
|
+
variant="ghost"
|
|
1016
|
+
type="button"
|
|
1017
|
+
aria-label={t("remove_item")}
|
|
1018
|
+
tooltip={t("remove_item")}
|
|
1019
|
+
data-arrange-btn="remove"
|
|
1020
|
+
onclick={() => removeItem(i)}
|
|
1021
|
+
>
|
|
1022
|
+
{@html iconTrash({ size: 18 })}
|
|
1023
|
+
</Button>
|
|
1024
|
+
</div>
|
|
1025
|
+
{/each}
|
|
1026
|
+
</div>
|
|
681
1027
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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" />
|
|
1028
|
+
<div class="pt-3 pl-1 flex items-end justify-between">
|
|
1029
|
+
<div class="text-xs stuic-field-options-muted">
|
|
1030
|
+
{@html t("arrange_help")}
|
|
697
1031
|
</div>
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
1032
|
+
{@render footerSubmitBtn()}
|
|
1033
|
+
</div>
|
|
1034
|
+
{:else}
|
|
1035
|
+
<div class="text-sm -mt-1 flex items-center">
|
|
1036
|
+
{#if isMultiple}
|
|
1037
|
+
<button
|
|
1038
|
+
type="button"
|
|
1039
|
+
onclick={() => _selectedColl.addMany(options.items)}
|
|
1040
|
+
class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
|
|
1041
|
+
tabindex={4}
|
|
1042
|
+
disabled={!options.size}
|
|
1043
|
+
>
|
|
1044
|
+
{@html t("select_all")}
|
|
1045
|
+
</button>
|
|
1046
|
+
{/if}
|
|
1047
|
+
<button
|
|
1048
|
+
type="button"
|
|
1049
|
+
onclick={() => {
|
|
1050
|
+
_selectedColl.clear();
|
|
1051
|
+
input?.focus();
|
|
1052
|
+
}}
|
|
1053
|
+
class="control flex items-center p-1 m-1 text-sm underline rounded stuic-field-options-control"
|
|
1054
|
+
data-disabled={!selected.items.length || undefined}
|
|
1055
|
+
tabindex={5}
|
|
1056
|
+
disabled={!selected.items.length}
|
|
701
1057
|
>
|
|
702
|
-
{@html t("
|
|
703
|
-
</
|
|
704
|
-
{/if}
|
|
1058
|
+
{@html t(cardinality === 1 ? "clear" : "clear_all")}
|
|
1059
|
+
</button>
|
|
705
1060
|
|
|
706
|
-
|
|
707
|
-
<
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
</div>
|
|
718
|
-
{/if}
|
|
1061
|
+
<span class="p-1 m-1 text-sm"> </span>
|
|
1062
|
+
<span
|
|
1063
|
+
class="flex-1 block justify-end text-right text-xs p-1 pr-2 stuic-field-options-muted"
|
|
1064
|
+
>
|
|
1065
|
+
{selected.items.length}
|
|
1066
|
+
{#if cardinality > 0 && cardinality < Infinity}
|
|
1067
|
+
{@html t("cardinality_of")} {cardinality}
|
|
1068
|
+
{/if}
|
|
1069
|
+
{@html t("cardinality_selected")}
|
|
1070
|
+
</span>
|
|
1071
|
+
</div>
|
|
719
1072
|
|
|
720
|
-
{#
|
|
721
|
-
|
|
1073
|
+
<!-- {#if options.items.length} -->
|
|
1074
|
+
<div
|
|
1075
|
+
id={`${id}-options`}
|
|
1076
|
+
class={[
|
|
1077
|
+
"options overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin",
|
|
1078
|
+
"h-55 max-h-55",
|
|
1079
|
+
]}
|
|
1080
|
+
bind:this={optionsBox}
|
|
1081
|
+
tabindex="-1"
|
|
1082
|
+
>
|
|
1083
|
+
{#if isFetching && !options.items.length}
|
|
1084
|
+
<div
|
|
1085
|
+
class="flex text-sm h-full items-center justify-center stuic-field-options-placeholder"
|
|
1086
|
+
>
|
|
1087
|
+
<Spinner class="w-4" />
|
|
1088
|
+
</div>
|
|
1089
|
+
{:else if !options.items.length && !allowUnknown}
|
|
722
1090
|
<div
|
|
723
|
-
class=
|
|
724
|
-
"mb-1 p-1 text-xs font-semibold uppercase tracking-wide stuic-field-options-optgroup",
|
|
725
|
-
classOptgroup
|
|
726
|
-
)}
|
|
1091
|
+
class="flex text-sm h-full items-center justify-center stuic-field-options-placeholder"
|
|
727
1092
|
>
|
|
728
|
-
{
|
|
1093
|
+
{@html t("no_results")}
|
|
729
1094
|
</div>
|
|
730
1095
|
{/if}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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")}
|
|
1096
|
+
|
|
1097
|
+
{#if !isFetching && allowUnknown && innerValue && !have_option_label_like(options.items, innerValue)}
|
|
1098
|
+
<div class="px-1">
|
|
1099
|
+
<ListItemButton
|
|
1100
|
+
bind:el={addNewBtn}
|
|
1101
|
+
onclick={add_new}
|
|
1102
|
+
focused={isAddNewBtnActive}
|
|
1103
|
+
class={classOption}
|
|
1104
|
+
classFocused={classOptionActive}
|
|
1105
|
+
>
|
|
1106
|
+
{t("add_new", { value: innerValue })}
|
|
1107
|
+
</ListItemButton>
|
|
1108
|
+
</div>
|
|
783
1109
|
{/if}
|
|
1110
|
+
|
|
1111
|
+
{#each groupedOptions as [_optgroup, _opts]}
|
|
1112
|
+
{#if _optgroup}
|
|
1113
|
+
<div
|
|
1114
|
+
class={twMerge(
|
|
1115
|
+
"mb-1 p-1 text-xs font-semibold uppercase tracking-wide stuic-field-options-optgroup",
|
|
1116
|
+
classOptgroup
|
|
1117
|
+
)}
|
|
1118
|
+
>
|
|
1119
|
+
{_optgroup}
|
|
1120
|
+
</div>
|
|
1121
|
+
{/if}
|
|
1122
|
+
<ul role="presentation" class="space-y-1">
|
|
1123
|
+
<!-- {#each options.items as item} -->
|
|
1124
|
+
{#each _opts as item (item[itemIdPropName])}
|
|
1125
|
+
{@const active =
|
|
1126
|
+
item[itemIdPropName] === options.active?.[itemIdPropName]}
|
|
1127
|
+
{@const isSelected =
|
|
1128
|
+
selected.items && _selectedColl.exists(item[itemIdPropName])}
|
|
1129
|
+
<li class:active role="presentation" class="px-1">
|
|
1130
|
+
<ListItemButton
|
|
1131
|
+
id={btn_id(item[itemIdPropName])}
|
|
1132
|
+
onclick={() => {
|
|
1133
|
+
if (isMultiple) {
|
|
1134
|
+
if (selected.isFull && !_selectedColl.exists(item)) {
|
|
1135
|
+
return notifications?.error(t("cardinality_full"), {
|
|
1136
|
+
ttl: 1000,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
_selectedColl.toggleAdd(item);
|
|
1140
|
+
} else {
|
|
1141
|
+
_selectedColl.clear();
|
|
1142
|
+
_selectedColl.add(item);
|
|
1143
|
+
submit();
|
|
1144
|
+
}
|
|
1145
|
+
}}
|
|
1146
|
+
active={isSelected}
|
|
1147
|
+
focused={active}
|
|
1148
|
+
contentBefore={showIcons ? getIconThc(isSelected) : undefined}
|
|
1149
|
+
classContentBefore={isSelected
|
|
1150
|
+
? "stuic-field-options-icon stuic-field-options-icon--selected"
|
|
1151
|
+
: "stuic-field-options-icon"}
|
|
1152
|
+
class={classOption}
|
|
1153
|
+
classActive={classOptionActive}
|
|
1154
|
+
classFocused={classOptionActive}
|
|
1155
|
+
tabindex={-1}
|
|
1156
|
+
role={isMultiple ? "checkbox" : "radio"}
|
|
1157
|
+
aria-checked={isSelected}
|
|
1158
|
+
>
|
|
1159
|
+
{_renderOptionLabel(item)}
|
|
1160
|
+
</ListItemButton>
|
|
1161
|
+
</li>
|
|
1162
|
+
{/each}
|
|
1163
|
+
</ul>
|
|
1164
|
+
{/each}
|
|
784
1165
|
</div>
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
>
|
|
796
|
-
|
|
797
|
-
|
|
1166
|
+
<!-- {/if} -->
|
|
1167
|
+
<div class="pt-3 pl-1 flex items-end justify-between">
|
|
1168
|
+
<div class="text-xs stuic-field-options-muted">
|
|
1169
|
+
<!-- Use arrows to navigate. Spacebar and Enter to select and/or submit. -->
|
|
1170
|
+
{#if allowUnknown}
|
|
1171
|
+
{@html t("unknown_allowed")}
|
|
1172
|
+
{:else}
|
|
1173
|
+
{@html t("unknown_not_allowed")}
|
|
1174
|
+
{/if}
|
|
1175
|
+
</div>
|
|
1176
|
+
<div>
|
|
1177
|
+
<Button
|
|
1178
|
+
class="control"
|
|
1179
|
+
type="button"
|
|
1180
|
+
intent="primary"
|
|
1181
|
+
onclick={async (e) => {
|
|
1182
|
+
e.preventDefault();
|
|
1183
|
+
try_submit(true);
|
|
1184
|
+
}}
|
|
1185
|
+
tabindex={3}
|
|
1186
|
+
>
|
|
1187
|
+
{@html t("submit")}
|
|
1188
|
+
</Button>
|
|
1189
|
+
</div>
|
|
798
1190
|
</div>
|
|
799
|
-
|
|
1191
|
+
{/if}
|
|
800
1192
|
</div>
|
|
801
1193
|
{/snippet}
|
|
802
1194
|
|
|
803
1195
|
{#snippet inputAfter()}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1196
|
+
<!-- the whole search row is hidden on the Arrange screen (close moves to the tabs) -->
|
|
1197
|
+
{#if !arrangeMode}
|
|
1198
|
+
<div
|
|
1199
|
+
class="flex pl-2 items-center justify-center stuic-field-options-placeholder"
|
|
1200
|
+
>
|
|
1201
|
+
{#if isFetching}
|
|
1202
|
+
<Spinner class="w-4" />
|
|
1203
|
+
{/if}
|
|
1204
|
+
</div>
|
|
1205
|
+
<!-- ordered fields have a single close ✕ in the tab header, so the
|
|
1206
|
+
search row drops its own ✕ (avoids a confusing double-✕) -->
|
|
1207
|
+
{#if !canArrange}
|
|
1208
|
+
<div class="flex pl-2 pr-1 items-center justify-center">
|
|
1209
|
+
<Button
|
|
1210
|
+
x
|
|
1211
|
+
variant="ghost"
|
|
1212
|
+
roundedFull
|
|
1213
|
+
type="button"
|
|
1214
|
+
tooltip={t("x_close")}
|
|
1215
|
+
onclick={(e) => {
|
|
1216
|
+
e.preventDefault();
|
|
1217
|
+
if (innerValue.trim() == "") {
|
|
1218
|
+
escape();
|
|
1219
|
+
return modalDialog.close();
|
|
1220
|
+
}
|
|
1221
|
+
innerValue = "";
|
|
1222
|
+
input?.focus();
|
|
1223
|
+
}}
|
|
1224
|
+
tabindex={2}
|
|
1225
|
+
/>
|
|
1226
|
+
</div>
|
|
809
1227
|
{/if}
|
|
810
|
-
|
|
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>
|
|
1228
|
+
{/if}
|
|
830
1229
|
{/snippet}
|
|
831
1230
|
|
|
832
1231
|
{#snippet inputBefore()}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1232
|
+
{#if !arrangeMode}
|
|
1233
|
+
<div
|
|
1234
|
+
class="flex flex-col items-center justify-center pl-3 stuic-field-options-muted"
|
|
1235
|
+
>
|
|
1236
|
+
{@html iconSearch({ size: 19 })}
|
|
1237
|
+
</div>
|
|
1238
|
+
{/if}
|
|
838
1239
|
{/snippet}
|
|
839
1240
|
</InputWrap>
|
|
840
1241
|
</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" / "Shuffle" 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
|
-
| `
|
|
370
|
-
| `
|
|
371
|
-
| `
|
|
372
|
-
| `
|
|
373
|
-
| `
|
|
374
|
-
| `
|
|
375
|
-
| `
|
|
376
|
-
| `
|
|
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
|
|
@@ -444,6 +472,33 @@ A modal-based multi-select/single-select component with search functionality, ty
|
|
|
444
472
|
|
|
445
473
|
---
|
|
446
474
|
|
|
475
|
+
## FieldAssets
|
|
476
|
+
|
|
477
|
+
Asset/image upload field with an inline thumbnail grid and a built-in lightbox preview
|
|
478
|
+
(`AssetsPreview`). Files are added via the picker button or by dragging them onto the
|
|
479
|
+
field; each shows as a thumbnail with its filename (and an upload progress indicator while
|
|
480
|
+
processing).
|
|
481
|
+
|
|
482
|
+
### Ordering the assets (`ordered`)
|
|
483
|
+
|
|
484
|
+
By default assets keep their upload order. Opt in with `ordered` to let users reorder them
|
|
485
|
+
manually: each thumbnail gains **Move earlier** / **Move later** controls (revealed on
|
|
486
|
+
hover/focus) that shift the asset one position in the sequence. Buttons only, no drag — the
|
|
487
|
+
field's drag gesture is reserved for file drops — with full keyboard support and aria-live
|
|
488
|
+
announcements. The chosen order is serialized to `value`.
|
|
489
|
+
|
|
490
|
+
```svelte
|
|
491
|
+
<FieldAssets
|
|
492
|
+
label="Gallery (ordered)"
|
|
493
|
+
name="gallery"
|
|
494
|
+
bind:value
|
|
495
|
+
{processAssets}
|
|
496
|
+
ordered
|
|
497
|
+
/>
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
447
502
|
## Honeypot & TimeTrap (anti-bot primitives)
|
|
448
503
|
|
|
449
504
|
Two small, **client-side, server-less** primitives for cheap spam mitigation. They produce **signals** — they do not block anything. Read the signal, then enforce on your server (the only place enforcement is trustworthy). Both are reusable on any form; [`ContactUsForm`](../ContactUsForm/README.md) composes them by default.
|
|
@@ -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;
|
package/dist/icons/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/icons/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.132.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.
|
|
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.
|
|
130
|
-
"@codemirror/view": "^6.43.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
160
|
-
"svelte-check": "^4.
|
|
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.
|
|
165
|
-
"vite": "^7.3.
|
|
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.
|
|
180
|
+
"libphonenumber-js": "^1.13.7",
|
|
181
181
|
"runed": "^0.23.4",
|
|
182
182
|
"tailwind-merge": "^3.6.0"
|
|
183
183
|
}
|