@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.
@@ -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
+ }
@@ -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";
@@ -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
- // Drag guard for area clicks (prevent click after drag/pan)
139
- let _wasDragged = false;
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
- onclick={(e: MouseEvent) => {
612
- if (_wasDragged) return;
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,4 @@
1
+ import Float from "./Float.svelte";
2
+ declare const Float: import("svelte").Component<Record<string, any>, {}, "">;
3
+ type Float = ReturnType<typeof Float>;
4
+ export default 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>