@marianmeres/stuic 3.119.0 → 3.121.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/actions/draggable.svelte.d.ts +62 -0
- package/dist/actions/draggable.svelte.js +93 -0
- package/dist/actions/index.d.ts +1 -0
- package/dist/actions/index.js +1 -0
- package/dist/components/AssetsPreview/_internal/AssetsPreviewContent.svelte +22 -15
- package/dist/components/Float/Float.fixture.svelte +26 -0
- package/dist/components/Float/Float.fixture.svelte.d.ts +4 -0
- package/dist/components/Float/Float.svelte +369 -0
- package/dist/components/Float/Float.svelte.d.ts +70 -0
- package/dist/components/Float/README.md +152 -0
- package/dist/components/Float/float-stack.d.ts +15 -0
- package/dist/components/Float/float-stack.js +28 -0
- package/dist/components/Float/float-utils.d.ts +68 -0
- package/dist/components/Float/float-utils.js +94 -0
- package/dist/components/Float/index.css +182 -0
- package/dist/components/Float/index.d.ts +2 -0
- package/dist/components/Float/index.js +2 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +11 -11
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the {@link draggable} action.
|
|
3
|
+
*/
|
|
4
|
+
export interface DraggableOptions {
|
|
5
|
+
/** Master switch. When `false` the action is inert. Default `true`. */
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* CSS selector for descendants that must NOT initiate a drag. A `pointerdown`
|
|
9
|
+
* whose target matches (via `closest`) is ignored — e.g. `"[data-no-drag]"`
|
|
10
|
+
* for buttons living inside an otherwise-draggable header.
|
|
11
|
+
*/
|
|
12
|
+
ignore?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Distance in px the pointer must travel before the gesture counts as a real
|
|
15
|
+
* drag (reported via `onEnd`'s `moved`). Used by consumers to distinguish a
|
|
16
|
+
* click/double-click from a drag. Default `3`.
|
|
17
|
+
*/
|
|
18
|
+
threshold?: number;
|
|
19
|
+
/** Called on a valid drag start (pointer captured). */
|
|
20
|
+
onStart?: (e: PointerEvent) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Called on every pointer move while dragging, with the cumulative delta from
|
|
23
|
+
* the drag's start point.
|
|
24
|
+
*/
|
|
25
|
+
onMove?: (delta: {
|
|
26
|
+
dx: number;
|
|
27
|
+
dy: number;
|
|
28
|
+
}, e: PointerEvent) => void;
|
|
29
|
+
/** Called when the drag ends; `moved` is `true` if it passed `threshold`. */
|
|
30
|
+
onEnd?: (info: {
|
|
31
|
+
moved: boolean;
|
|
32
|
+
}, e: PointerEvent) => void;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A Svelte action that turns an element into a drag handle and reports pointer
|
|
36
|
+
* deltas — without taking any opinion on what gets moved. The consumer owns the
|
|
37
|
+
* positioned element, clamping, and persistence; this action only translates
|
|
38
|
+
* pointer gestures into `{dx, dy}` deltas.
|
|
39
|
+
*
|
|
40
|
+
* Built on Pointer Events with pointer capture, so a single code path covers
|
|
41
|
+
* mouse, touch and pen, and the drag keeps tracking even if the pointer leaves
|
|
42
|
+
* the element. Pair with `touch-action: none` on the handle to stop the browser
|
|
43
|
+
* from scrolling/zooming mid-drag.
|
|
44
|
+
*
|
|
45
|
+
* @param node - The handle element.
|
|
46
|
+
* @param fn - Function returning the current {@link DraggableOptions}.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```svelte
|
|
50
|
+
* <div
|
|
51
|
+
* use:draggable={() => ({
|
|
52
|
+
* ignore: "[data-no-drag]",
|
|
53
|
+
* onStart: () => (start = { ...pos }),
|
|
54
|
+
* onMove: ({ dx, dy }) => (pos = clamp(start.x + dx, start.y + dy)),
|
|
55
|
+
* onEnd: ({ moved }) => (wasDragged = moved),
|
|
56
|
+
* })}
|
|
57
|
+
* >
|
|
58
|
+
* drag me
|
|
59
|
+
* </div>
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export declare function draggable(node: HTMLElement, fn?: () => DraggableOptions): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Svelte action that turns an element into a drag handle and reports pointer
|
|
3
|
+
* deltas — without taking any opinion on what gets moved. The consumer owns the
|
|
4
|
+
* positioned element, clamping, and persistence; this action only translates
|
|
5
|
+
* pointer gestures into `{dx, dy}` deltas.
|
|
6
|
+
*
|
|
7
|
+
* Built on Pointer Events with pointer capture, so a single code path covers
|
|
8
|
+
* mouse, touch and pen, and the drag keeps tracking even if the pointer leaves
|
|
9
|
+
* the element. Pair with `touch-action: none` on the handle to stop the browser
|
|
10
|
+
* from scrolling/zooming mid-drag.
|
|
11
|
+
*
|
|
12
|
+
* @param node - The handle element.
|
|
13
|
+
* @param fn - Function returning the current {@link DraggableOptions}.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```svelte
|
|
17
|
+
* <div
|
|
18
|
+
* use:draggable={() => ({
|
|
19
|
+
* ignore: "[data-no-drag]",
|
|
20
|
+
* onStart: () => (start = { ...pos }),
|
|
21
|
+
* onMove: ({ dx, dy }) => (pos = clamp(start.x + dx, start.y + dy)),
|
|
22
|
+
* onEnd: ({ moved }) => (wasDragged = moved),
|
|
23
|
+
* })}
|
|
24
|
+
* >
|
|
25
|
+
* drag me
|
|
26
|
+
* </div>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function draggable(node, fn) {
|
|
30
|
+
$effect(() => {
|
|
31
|
+
const { enabled = true, ignore, threshold = 3, onStart, onMove, onEnd, } = fn?.() || {};
|
|
32
|
+
if (!enabled)
|
|
33
|
+
return;
|
|
34
|
+
let dragging = false;
|
|
35
|
+
let moved = false;
|
|
36
|
+
let startX = 0;
|
|
37
|
+
let startY = 0;
|
|
38
|
+
let pointerId = -1;
|
|
39
|
+
function onPointerDown(e) {
|
|
40
|
+
// primary button / touch / pen only
|
|
41
|
+
if (e.button !== 0 && e.pointerType === "mouse")
|
|
42
|
+
return;
|
|
43
|
+
if (ignore && e.target?.closest?.(ignore))
|
|
44
|
+
return;
|
|
45
|
+
dragging = true;
|
|
46
|
+
moved = false;
|
|
47
|
+
startX = e.clientX;
|
|
48
|
+
startY = e.clientY;
|
|
49
|
+
pointerId = e.pointerId;
|
|
50
|
+
try {
|
|
51
|
+
node.setPointerCapture(e.pointerId);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* capture is best-effort */
|
|
55
|
+
}
|
|
56
|
+
document.body.style.userSelect = "none";
|
|
57
|
+
onStart?.(e);
|
|
58
|
+
}
|
|
59
|
+
function onPointerMove(e) {
|
|
60
|
+
if (!dragging)
|
|
61
|
+
return;
|
|
62
|
+
const dx = e.clientX - startX;
|
|
63
|
+
const dy = e.clientY - startY;
|
|
64
|
+
if (!moved && Math.hypot(dx, dy) > threshold)
|
|
65
|
+
moved = true;
|
|
66
|
+
onMove?.({ dx, dy }, e);
|
|
67
|
+
}
|
|
68
|
+
function onPointerUp(e) {
|
|
69
|
+
if (!dragging)
|
|
70
|
+
return;
|
|
71
|
+
dragging = false;
|
|
72
|
+
try {
|
|
73
|
+
node.releasePointerCapture(pointerId);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* noop */
|
|
77
|
+
}
|
|
78
|
+
document.body.style.userSelect = "";
|
|
79
|
+
onEnd?.({ moved }, e);
|
|
80
|
+
}
|
|
81
|
+
node.addEventListener("pointerdown", onPointerDown);
|
|
82
|
+
node.addEventListener("pointermove", onPointerMove);
|
|
83
|
+
node.addEventListener("pointerup", onPointerUp);
|
|
84
|
+
node.addEventListener("pointercancel", onPointerUp);
|
|
85
|
+
return () => {
|
|
86
|
+
node.removeEventListener("pointerdown", onPointerDown);
|
|
87
|
+
node.removeEventListener("pointermove", onPointerMove);
|
|
88
|
+
node.removeEventListener("pointerup", onPointerUp);
|
|
89
|
+
node.removeEventListener("pointercancel", onPointerUp);
|
|
90
|
+
document.body.style.userSelect = "";
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
package/dist/actions/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./autogrow.svelte.js";
|
|
|
2
2
|
export * from "./onboarding/onboarding.svelte.js";
|
|
3
3
|
export * from "./autoscroll.js";
|
|
4
4
|
export * from "./dim-behind/dim-behind.svelte.js";
|
|
5
|
+
export * from "./draggable.svelte.js";
|
|
5
6
|
export * from "./file-dropzone.svelte.js";
|
|
6
7
|
export * from "./focus-trap.js";
|
|
7
8
|
export * from "./highlight-dragover.svelte.js";
|
package/dist/actions/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./autogrow.svelte.js";
|
|
|
2
2
|
export * from "./onboarding/onboarding.svelte.js";
|
|
3
3
|
export * from "./autoscroll.js";
|
|
4
4
|
export * from "./dim-behind/dim-behind.svelte.js";
|
|
5
|
+
export * from "./draggable.svelte.js";
|
|
5
6
|
export * from "./file-dropzone.svelte.js";
|
|
6
7
|
export * from "./focus-trap.js";
|
|
7
8
|
export * from "./highlight-dragover.svelte.js";
|
|
@@ -135,8 +135,24 @@
|
|
|
135
135
|
|
|
136
136
|
$effect(() => () => clearTimeout(_previewSettleTimer));
|
|
137
137
|
|
|
138
|
-
//
|
|
139
|
-
|
|
138
|
+
// ---- Tap vs drag discrimination for area activation ----
|
|
139
|
+
// Areas activate via pointer events (pointerdown/pointerup), not a synthesized
|
|
140
|
+
// `click`. On touch the synthesized click is dropped whenever the gesture involves
|
|
141
|
+
// any movement/scroll — unreliable in general, worst on inner SVG shapes (iOS
|
|
142
|
+
// Safari) — which made small, closely-spaced area taps a no-op on mobile. A tap is
|
|
143
|
+
// a pointerup landing within TAP_SLOP_PX of its pointerdown. (Mirrors Book.svelte.)
|
|
144
|
+
const TAP_SLOP_PX = 10;
|
|
145
|
+
let _tapDownX = 0;
|
|
146
|
+
let _tapDownY = 0;
|
|
147
|
+
|
|
148
|
+
function handleTapDown(e: PointerEvent) {
|
|
149
|
+
_tapDownX = e.clientX;
|
|
150
|
+
_tapDownY = e.clientY;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isTap(e: PointerEvent): boolean {
|
|
154
|
+
return Math.hypot(e.clientX - _tapDownX, e.clientY - _tapDownY) <= TAP_SLOP_PX;
|
|
155
|
+
}
|
|
140
156
|
|
|
141
157
|
const BUTTON_CLS = "stuic-assets-preview-control pointer-events-auto p-0!";
|
|
142
158
|
|
|
@@ -279,7 +295,6 @@
|
|
|
279
295
|
|
|
280
296
|
// Pan/drag handlers
|
|
281
297
|
function panStart(e: MouseEvent | TouchEvent) {
|
|
282
|
-
_wasDragged = false;
|
|
283
298
|
// Detect two-finger pinch gesture
|
|
284
299
|
if ("touches" in e && e.touches.length === 2) {
|
|
285
300
|
if (noZoom) return;
|
|
@@ -336,11 +351,6 @@
|
|
|
336
351
|
const newPanX = startPanX + (clientX - startMouseX);
|
|
337
352
|
const newPanY = startPanY + (clientY - startMouseY);
|
|
338
353
|
|
|
339
|
-
// Track drag for area click guard
|
|
340
|
-
if (Math.abs(clientX - startMouseX) > 3 || Math.abs(clientY - startMouseY) > 3) {
|
|
341
|
-
_wasDragged = true;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
354
|
if (clampPan) {
|
|
345
355
|
const clamped = getClampedPan(newPanX, newPanY);
|
|
346
356
|
panX = clamped.x;
|
|
@@ -389,10 +399,6 @@
|
|
|
389
399
|
}
|
|
390
400
|
|
|
391
401
|
isPanning = false;
|
|
392
|
-
// Reset _wasDragged after a microtask so onclick handlers can check it first
|
|
393
|
-
requestAnimationFrame(() => {
|
|
394
|
-
_wasDragged = false;
|
|
395
|
-
});
|
|
396
402
|
}
|
|
397
403
|
|
|
398
404
|
async function slideToIndex(targetIdx: number, direction: "next" | "prev") {
|
|
@@ -565,6 +571,7 @@
|
|
|
565
571
|
use:interactable
|
|
566
572
|
bind:this={containerEl}
|
|
567
573
|
class="w-full h-full overflow-hidden flex items-center justify-center relative"
|
|
574
|
+
style:touch-action="none"
|
|
568
575
|
>
|
|
569
576
|
<img
|
|
570
577
|
use:pannable
|
|
@@ -600,7 +607,6 @@
|
|
|
600
607
|
style:transform-origin="center center"
|
|
601
608
|
>
|
|
602
609
|
{#each previewAsset.areas as area, i (`${area.id}-${i}`)}
|
|
603
|
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
604
610
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
605
611
|
<rect
|
|
606
612
|
x={area.x}
|
|
@@ -608,8 +614,9 @@
|
|
|
608
614
|
width={area.w}
|
|
609
615
|
height={area.h}
|
|
610
616
|
class="stuic-assets-preview-area"
|
|
611
|
-
|
|
612
|
-
|
|
617
|
+
onpointerdown={handleTapDown}
|
|
618
|
+
onpointerup={(e: PointerEvent) => {
|
|
619
|
+
if (!isTap(e)) return;
|
|
613
620
|
e.stopPropagation();
|
|
614
621
|
onAreaClick({ area, asset: previewAsset });
|
|
615
622
|
}}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Float's runtime control is imperative (methods via a ref), so the browser
|
|
3
|
+
// test drives it through this fixture: it holds the `bind:this` ref and exposes
|
|
4
|
+
// testid buttons that call each method. Float props are forwarded via `...rest`.
|
|
5
|
+
import Float from "./Float.svelte";
|
|
6
|
+
|
|
7
|
+
let float = $state<Float>();
|
|
8
|
+
let { ...rest } = $props();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<button data-testid="m-minimize" onclick={() => float?.minimize()}>minimize</button>
|
|
12
|
+
<button data-testid="m-expand" onclick={() => float?.expand()}>expand</button>
|
|
13
|
+
<button data-testid="m-toggle" onclick={() => float?.toggleMinimize()}>toggle</button>
|
|
14
|
+
<button data-testid="m-moveto" onclick={() => float?.moveTo(123, 45)}>moveTo</button>
|
|
15
|
+
<button
|
|
16
|
+
data-testid="m-placement-br"
|
|
17
|
+
onclick={() => float?.moveToPlacement("bottom-right")}
|
|
18
|
+
>
|
|
19
|
+
placement
|
|
20
|
+
</button>
|
|
21
|
+
<button data-testid="m-front" onclick={() => float?.bringToFront()}>front</button>
|
|
22
|
+
|
|
23
|
+
<Float bind:this={float} title="Panel" {...rest}>
|
|
24
|
+
<input data-testid="body-input" type="text" />
|
|
25
|
+
<p>body content</p>
|
|
26
|
+
</Float>
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import type { THC } from "../Thc/Thc.svelte";
|
|
5
|
+
import type { FloatPlacement, FloatPoint } from "./float-utils.js";
|
|
6
|
+
|
|
7
|
+
let _uid = 0;
|
|
8
|
+
|
|
9
|
+
export interface Props extends Omit<
|
|
10
|
+
HTMLAttributes<HTMLDivElement>,
|
|
11
|
+
"title" | "draggable" | "class"
|
|
12
|
+
> {
|
|
13
|
+
/** Header title (rendered via `Thc`). Stays visible when minimized. */
|
|
14
|
+
title?: THC;
|
|
15
|
+
/** Initial left position in px. Overrides the `placement` x. */
|
|
16
|
+
x?: number;
|
|
17
|
+
/** Initial top position in px. Overrides the `placement` y. */
|
|
18
|
+
y?: number;
|
|
19
|
+
/** Initial placement preset, used for any axis `x`/`y` does not pin. */
|
|
20
|
+
placement?: FloatPlacement;
|
|
21
|
+
/** Edge gap (px) used when resolving `placement`. Number or `{x,y}`. */
|
|
22
|
+
offset?: number | Partial<FloatPoint>;
|
|
23
|
+
/** Panel width: a number (px) or any CSS length. Defaults to a token. */
|
|
24
|
+
width?: number | string;
|
|
25
|
+
/** Initial collapsed state. Runtime changes go through the methods below. */
|
|
26
|
+
minimized?: boolean;
|
|
27
|
+
/** Enable drag-repositioning by the header. Default `true`. */
|
|
28
|
+
draggable?: boolean;
|
|
29
|
+
/** Show the close `×` and enable `close()` / Escape. Default `false`. */
|
|
30
|
+
closable?: boolean;
|
|
31
|
+
/** Called by `close()`, the `×` button, and Escape (when closable). */
|
|
32
|
+
onClose?: () => void;
|
|
33
|
+
/** Close on Escape when `closable`. Default `true`. */
|
|
34
|
+
closeOnEscape?: boolean;
|
|
35
|
+
/** Raise above sibling Floats on pointerdown. Default `true`. */
|
|
36
|
+
bringToFrontOnClick?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Opt-in persistence key. When set, `{x, y, minimized}` is remembered
|
|
39
|
+
* across reloads in localStorage under `stuic-float-<storageKey>`.
|
|
40
|
+
*/
|
|
41
|
+
storageKey?: string;
|
|
42
|
+
/** Minimum gap kept from every viewport edge while clamping. Default `0`. */
|
|
43
|
+
margin?: number;
|
|
44
|
+
/** Accessible labels for the built-in buttons. */
|
|
45
|
+
minimizeLabel?: string;
|
|
46
|
+
restoreLabel?: string;
|
|
47
|
+
closeLabel?: string;
|
|
48
|
+
/** Leading header slot (e.g. a grip/icon). Drag still works over it. */
|
|
49
|
+
icon?: Snippet;
|
|
50
|
+
/** Header actions slot, placed left of the minimize/close buttons. */
|
|
51
|
+
actions?: Snippet;
|
|
52
|
+
/** Body content (hidden when minimized). */
|
|
53
|
+
children?: Snippet;
|
|
54
|
+
/** Root element classes (merged). */
|
|
55
|
+
class?: string;
|
|
56
|
+
classHeader?: string;
|
|
57
|
+
classTitle?: string;
|
|
58
|
+
classActions?: string;
|
|
59
|
+
classBody?: string;
|
|
60
|
+
/** Drop all default styling (still positioned/draggable). */
|
|
61
|
+
unstyled?: boolean;
|
|
62
|
+
/** Bindable root element reference. */
|
|
63
|
+
el?: HTMLDivElement;
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<script lang="ts">
|
|
68
|
+
import { untrack } from "svelte";
|
|
69
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
70
|
+
import { localStorageState } from "../../utils/persistent-state.svelte.js";
|
|
71
|
+
import { draggable as draggableAction } from "../../actions/draggable.svelte.js";
|
|
72
|
+
import { iconChevronDown, iconX } from "../../icons/index.js";
|
|
73
|
+
import Thc, { isTHCNotEmpty } from "../Thc/Thc.svelte";
|
|
74
|
+
import { clampToViewport, resolvePlacement, type FloatSize } from "./float-utils.js";
|
|
75
|
+
import { nextFloatOrder } from "./float-stack.js";
|
|
76
|
+
|
|
77
|
+
let {
|
|
78
|
+
title,
|
|
79
|
+
x: xProp,
|
|
80
|
+
y: yProp,
|
|
81
|
+
placement = "top-right",
|
|
82
|
+
offset = 16,
|
|
83
|
+
width,
|
|
84
|
+
minimized: minimizedProp = false,
|
|
85
|
+
draggable = true,
|
|
86
|
+
closable = false,
|
|
87
|
+
onClose,
|
|
88
|
+
closeOnEscape = true,
|
|
89
|
+
bringToFrontOnClick = true,
|
|
90
|
+
storageKey,
|
|
91
|
+
margin = 0,
|
|
92
|
+
minimizeLabel = "Minimize",
|
|
93
|
+
restoreLabel = "Restore",
|
|
94
|
+
closeLabel = "Close",
|
|
95
|
+
icon,
|
|
96
|
+
actions,
|
|
97
|
+
children,
|
|
98
|
+
class: classProp,
|
|
99
|
+
classHeader,
|
|
100
|
+
classTitle,
|
|
101
|
+
classActions,
|
|
102
|
+
classBody,
|
|
103
|
+
unstyled = false,
|
|
104
|
+
el = $bindable(),
|
|
105
|
+
...rest
|
|
106
|
+
}: Props = $props();
|
|
107
|
+
|
|
108
|
+
const titleId = `stuic-float-title-${_uid++}`;
|
|
109
|
+
|
|
110
|
+
// reactive runtime state. `minimizedProp`/`storageKey` are initial-only by
|
|
111
|
+
// design (runtime changes flow through the methods), so we capture them once.
|
|
112
|
+
let x = $state(0);
|
|
113
|
+
let y = $state(0);
|
|
114
|
+
let minimized = $state(untrack(() => minimizedProp));
|
|
115
|
+
let dragging = $state(false);
|
|
116
|
+
let zOrder = $state(0);
|
|
117
|
+
let initialized = $state(false);
|
|
118
|
+
|
|
119
|
+
// optional persistence (created once; key is not expected to change)
|
|
120
|
+
type Persisted = { x: number | null; y: number | null; minimized: boolean };
|
|
121
|
+
const persist = untrack(() =>
|
|
122
|
+
storageKey
|
|
123
|
+
? localStorageState<Persisted>(`stuic-float-${storageKey}`, {
|
|
124
|
+
x: null,
|
|
125
|
+
y: null,
|
|
126
|
+
minimized: minimizedProp,
|
|
127
|
+
})
|
|
128
|
+
: null
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
function viewport(): FloatSize {
|
|
132
|
+
if (typeof window === "undefined") return { width: 0, height: 0 };
|
|
133
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function currentSize(): FloatSize {
|
|
137
|
+
return { width: el?.offsetWidth ?? 0, height: el?.offsetHeight ?? 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Persist imperatively (not via an $effect): an effect that writes persist.current
|
|
141
|
+
// while the init effect reads it forms a reactive cycle, and it would also hit
|
|
142
|
+
// localStorage on every pointermove. We save at meaningful end points instead.
|
|
143
|
+
function persistNow() {
|
|
144
|
+
if (persist) persist.current = { x, y, minimized };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// one-time position resolution (stored -> explicit x/y -> placement)
|
|
148
|
+
$effect(() => {
|
|
149
|
+
if (initialized || !el) return;
|
|
150
|
+
|
|
151
|
+
// read the stored value once, untracked (no reactive dependency on it)
|
|
152
|
+
const stored = persist ? untrack(() => persist.current) : undefined;
|
|
153
|
+
if (stored && stored.x != null && stored.y != null) {
|
|
154
|
+
x = stored.x;
|
|
155
|
+
y = stored.y;
|
|
156
|
+
if (typeof stored.minimized === "boolean") minimized = stored.minimized;
|
|
157
|
+
} else {
|
|
158
|
+
const base = resolvePlacement(placement, currentSize(), viewport(), offset);
|
|
159
|
+
const next = clampToViewport(
|
|
160
|
+
{ x: xProp ?? base.x, y: yProp ?? base.y },
|
|
161
|
+
currentSize(),
|
|
162
|
+
viewport(),
|
|
163
|
+
margin
|
|
164
|
+
);
|
|
165
|
+
x = next.x;
|
|
166
|
+
y = next.y;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
zOrder = nextFloatOrder();
|
|
170
|
+
initialized = true;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// keep inside the viewport on window resize
|
|
174
|
+
$effect(() => {
|
|
175
|
+
if (typeof window === "undefined") return;
|
|
176
|
+
function onResize() {
|
|
177
|
+
if (!initialized) return;
|
|
178
|
+
const next = clampToViewport({ x, y }, currentSize(), viewport(), margin);
|
|
179
|
+
x = next.x;
|
|
180
|
+
y = next.y;
|
|
181
|
+
persistNow();
|
|
182
|
+
}
|
|
183
|
+
window.addEventListener("resize", onResize);
|
|
184
|
+
return () => window.removeEventListener("resize", onResize);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// --- drag wiring -------------------------------------------------------------
|
|
188
|
+
let dragStart = { x: 0, y: 0 };
|
|
189
|
+
let lastDragMoved = false;
|
|
190
|
+
|
|
191
|
+
function onDragStart() {
|
|
192
|
+
dragStart = { x, y };
|
|
193
|
+
dragging = true;
|
|
194
|
+
lastDragMoved = false;
|
|
195
|
+
}
|
|
196
|
+
function onDragMove({ dx, dy }: { dx: number; dy: number }) {
|
|
197
|
+
const next = clampToViewport(
|
|
198
|
+
{ x: dragStart.x + dx, y: dragStart.y + dy },
|
|
199
|
+
currentSize(),
|
|
200
|
+
viewport(),
|
|
201
|
+
margin
|
|
202
|
+
);
|
|
203
|
+
x = next.x;
|
|
204
|
+
y = next.y;
|
|
205
|
+
}
|
|
206
|
+
function onDragEnd({ moved }: { moved: boolean }) {
|
|
207
|
+
dragging = false;
|
|
208
|
+
lastDragMoved = moved;
|
|
209
|
+
if (moved) persistNow();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function onRootPointerDown() {
|
|
213
|
+
if (bringToFrontOnClick) bringToFront();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function onHeaderDblClick(e: MouseEvent) {
|
|
217
|
+
// ignore the dbl-click synthesized right after a real drag
|
|
218
|
+
if (lastDragMoved) {
|
|
219
|
+
lastDragMoved = false;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if ((e.target as Element)?.closest?.("[data-no-drag]")) return;
|
|
223
|
+
toggleMinimize();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onRootKeydown(e: KeyboardEvent) {
|
|
227
|
+
if (e.key === "Escape" && closable && closeOnEscape) {
|
|
228
|
+
e.stopPropagation();
|
|
229
|
+
close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- imperative API (via bind:this) -----------------------------------------
|
|
234
|
+
/** Move to an absolute `(x, y)` (clamped into the viewport). */
|
|
235
|
+
export function moveTo(nx: number, ny: number) {
|
|
236
|
+
const next = clampToViewport({ x: nx, y: ny }, currentSize(), viewport(), margin);
|
|
237
|
+
x = next.x;
|
|
238
|
+
y = next.y;
|
|
239
|
+
persistNow();
|
|
240
|
+
}
|
|
241
|
+
/** Move to a named placement preset (optionally with a custom offset). */
|
|
242
|
+
export function moveToPlacement(
|
|
243
|
+
a: FloatPlacement,
|
|
244
|
+
o: number | Partial<FloatPoint> = offset
|
|
245
|
+
) {
|
|
246
|
+
const p = resolvePlacement(a, currentSize(), viewport(), o);
|
|
247
|
+
x = p.x;
|
|
248
|
+
y = p.y;
|
|
249
|
+
persistNow();
|
|
250
|
+
}
|
|
251
|
+
/** Current top-left position. */
|
|
252
|
+
export function getPosition(): FloatPoint {
|
|
253
|
+
return { x, y };
|
|
254
|
+
}
|
|
255
|
+
/** Collapse to the title bar. */
|
|
256
|
+
export function minimize() {
|
|
257
|
+
minimized = true;
|
|
258
|
+
persistNow();
|
|
259
|
+
}
|
|
260
|
+
/** Expand the body. */
|
|
261
|
+
export function expand() {
|
|
262
|
+
minimized = false;
|
|
263
|
+
persistNow();
|
|
264
|
+
}
|
|
265
|
+
/** Toggle minimized state. */
|
|
266
|
+
export function toggleMinimize() {
|
|
267
|
+
minimized = !minimized;
|
|
268
|
+
persistNow();
|
|
269
|
+
}
|
|
270
|
+
/** Whether currently minimized. */
|
|
271
|
+
export function isMinimized(): boolean {
|
|
272
|
+
return minimized;
|
|
273
|
+
}
|
|
274
|
+
/** Fire `onClose` (consumer decides to unmount). */
|
|
275
|
+
export function close() {
|
|
276
|
+
onClose?.();
|
|
277
|
+
}
|
|
278
|
+
/** Raise above sibling Floats. */
|
|
279
|
+
export function bringToFront() {
|
|
280
|
+
zOrder = nextFloatOrder();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- derived presentation ----------------------------------------------------
|
|
284
|
+
const cssWidth = $derived(
|
|
285
|
+
width == null ? undefined : typeof width === "number" ? `${width}px` : width
|
|
286
|
+
);
|
|
287
|
+
</script>
|
|
288
|
+
|
|
289
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
290
|
+
<div
|
|
291
|
+
bind:this={el}
|
|
292
|
+
{...rest}
|
|
293
|
+
class={twMerge(unstyled ? "" : "stuic-float", classProp)}
|
|
294
|
+
role="dialog"
|
|
295
|
+
aria-modal="false"
|
|
296
|
+
aria-labelledby={isTHCNotEmpty(title) ? titleId : undefined}
|
|
297
|
+
data-minimized={minimized}
|
|
298
|
+
data-dragging={dragging}
|
|
299
|
+
data-draggable={draggable}
|
|
300
|
+
style:left="{x}px"
|
|
301
|
+
style:top="{y}px"
|
|
302
|
+
style:width={cssWidth}
|
|
303
|
+
style:visibility={initialized ? undefined : "hidden"}
|
|
304
|
+
style:--stuic-float-z-order={zOrder}
|
|
305
|
+
onpointerdown={onRootPointerDown}
|
|
306
|
+
onkeydown={onRootKeydown}
|
|
307
|
+
>
|
|
308
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
309
|
+
<div
|
|
310
|
+
class={twMerge(unstyled ? "" : "stuic-float-header", classHeader)}
|
|
311
|
+
ondblclick={onHeaderDblClick}
|
|
312
|
+
use:draggableAction={() => ({
|
|
313
|
+
enabled: draggable,
|
|
314
|
+
ignore: "[data-no-drag]",
|
|
315
|
+
onStart: onDragStart,
|
|
316
|
+
onMove: onDragMove,
|
|
317
|
+
onEnd: onDragEnd,
|
|
318
|
+
})}
|
|
319
|
+
>
|
|
320
|
+
{#if icon}
|
|
321
|
+
<span class={unstyled ? "" : "stuic-float-icon"}>{@render icon()}</span>
|
|
322
|
+
{/if}
|
|
323
|
+
|
|
324
|
+
<div id={titleId} class={twMerge(unstyled ? "" : "stuic-float-title", classTitle)}>
|
|
325
|
+
{#if isTHCNotEmpty(title)}<Thc thc={title!} />{/if}
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
<div
|
|
329
|
+
class={twMerge(unstyled ? "" : "stuic-float-actions", classActions)}
|
|
330
|
+
data-no-drag
|
|
331
|
+
>
|
|
332
|
+
{#if actions}{@render actions()}{/if}
|
|
333
|
+
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
class={unstyled ? "" : "stuic-float-btn"}
|
|
337
|
+
data-no-drag
|
|
338
|
+
aria-label={minimized ? restoreLabel : minimizeLabel}
|
|
339
|
+
title={minimized ? restoreLabel : minimizeLabel}
|
|
340
|
+
onclick={toggleMinimize}
|
|
341
|
+
>
|
|
342
|
+
<span class={unstyled ? "" : "stuic-float-chevron"}>
|
|
343
|
+
{@html iconChevronDown({ size: 16 })}
|
|
344
|
+
</span>
|
|
345
|
+
</button>
|
|
346
|
+
|
|
347
|
+
{#if closable}
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
class={unstyled ? "" : "stuic-float-btn"}
|
|
351
|
+
data-no-drag
|
|
352
|
+
aria-label={closeLabel}
|
|
353
|
+
title={closeLabel}
|
|
354
|
+
onclick={() => close()}
|
|
355
|
+
>
|
|
356
|
+
{@html iconX({ size: 16 })}
|
|
357
|
+
</button>
|
|
358
|
+
{/if}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<div class={unstyled ? "" : "stuic-float-body-wrap"}>
|
|
363
|
+
<div class={unstyled ? "" : "stuic-float-body-inner"}>
|
|
364
|
+
<div class={twMerge(unstyled ? "" : "stuic-float-body", classBody)}>
|
|
365
|
+
{@render children?.()}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|