@marianmeres/stuic 3.120.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";
@@ -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>
@@ -0,0 +1,70 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { THC } from "../Thc/Thc.svelte";
4
+ import type { FloatPlacement, FloatPoint } from "./float-utils.js";
5
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "title" | "draggable" | "class"> {
6
+ /** Header title (rendered via `Thc`). Stays visible when minimized. */
7
+ title?: THC;
8
+ /** Initial left position in px. Overrides the `placement` x. */
9
+ x?: number;
10
+ /** Initial top position in px. Overrides the `placement` y. */
11
+ y?: number;
12
+ /** Initial placement preset, used for any axis `x`/`y` does not pin. */
13
+ placement?: FloatPlacement;
14
+ /** Edge gap (px) used when resolving `placement`. Number or `{x,y}`. */
15
+ offset?: number | Partial<FloatPoint>;
16
+ /** Panel width: a number (px) or any CSS length. Defaults to a token. */
17
+ width?: number | string;
18
+ /** Initial collapsed state. Runtime changes go through the methods below. */
19
+ minimized?: boolean;
20
+ /** Enable drag-repositioning by the header. Default `true`. */
21
+ draggable?: boolean;
22
+ /** Show the close `×` and enable `close()` / Escape. Default `false`. */
23
+ closable?: boolean;
24
+ /** Called by `close()`, the `×` button, and Escape (when closable). */
25
+ onClose?: () => void;
26
+ /** Close on Escape when `closable`. Default `true`. */
27
+ closeOnEscape?: boolean;
28
+ /** Raise above sibling Floats on pointerdown. Default `true`. */
29
+ bringToFrontOnClick?: boolean;
30
+ /**
31
+ * Opt-in persistence key. When set, `{x, y, minimized}` is remembered
32
+ * across reloads in localStorage under `stuic-float-<storageKey>`.
33
+ */
34
+ storageKey?: string;
35
+ /** Minimum gap kept from every viewport edge while clamping. Default `0`. */
36
+ margin?: number;
37
+ /** Accessible labels for the built-in buttons. */
38
+ minimizeLabel?: string;
39
+ restoreLabel?: string;
40
+ closeLabel?: string;
41
+ /** Leading header slot (e.g. a grip/icon). Drag still works over it. */
42
+ icon?: Snippet;
43
+ /** Header actions slot, placed left of the minimize/close buttons. */
44
+ actions?: Snippet;
45
+ /** Body content (hidden when minimized). */
46
+ children?: Snippet;
47
+ /** Root element classes (merged). */
48
+ class?: string;
49
+ classHeader?: string;
50
+ classTitle?: string;
51
+ classActions?: string;
52
+ classBody?: string;
53
+ /** Drop all default styling (still positioned/draggable). */
54
+ unstyled?: boolean;
55
+ /** Bindable root element reference. */
56
+ el?: HTMLDivElement;
57
+ }
58
+ declare const Float: import("svelte").Component<Props, {
59
+ moveTo: (nx: number, ny: number) => void;
60
+ moveToPlacement: (a: FloatPlacement, o?: number | Partial<FloatPoint>) => void;
61
+ getPosition: () => FloatPoint;
62
+ minimize: () => void;
63
+ expand: () => void;
64
+ toggleMinimize: () => void;
65
+ isMinimized: () => boolean;
66
+ close: () => void;
67
+ bringToFront: () => void;
68
+ }, "el">;
69
+ type Float = ReturnType<typeof Float>;
70
+ export default Float;
@@ -0,0 +1,152 @@
1
+ # Float
2
+
3
+ A non-modal, draggable, collapsible floating panel — the reusable "container" half of a
4
+ dev/inspector tweak panel (dat.GUI / Tweakpane style). It has a header (optional leading
5
+ icon, a `THC` title, an actions slot, and minimize/close buttons) and an arbitrary body.
6
+
7
+ - **Positioned by params**: numeric `x`/`y` **or** a named `placement` preset (corners / edges / center).
8
+ - **`position: fixed`** relative to the viewport, with drag **clamped** so it never leaves the screen.
9
+ - **Draggable** by the whole header (buttons excepted).
10
+ - **Minimizable** to just the title bar (header button, double-click header, or methods).
11
+ - **Imperative control** via a `bind:this` ref (mirrors `Modal`/`ModalDialog`).
12
+ - Optional: leading icon, header actions, **localStorage persistence**, and **bring-to-front** stacking.
13
+
14
+ Resizing is intentionally out of scope (v1 is drag + minimize).
15
+
16
+ ## Props
17
+
18
+ | Prop | Type | Default | Description |
19
+ | --------------------------------------------------------------------- | ------------------ | ------------- | ------------------------------------------------------------------------ |
20
+ | `title` | `THC` | - | Header title (text/html/component/snippet). Stays visible when minimized |
21
+ | `x` / `y` | `number` | - | Initial position in px. Overrides the corresponding `placement` axis |
22
+ | `placement` | `FloatPlacement` | `"top-right"` | Initial preset for any axis `x`/`y` does not pin |
23
+ | `offset` | `number \| {x,y}` | `16` | Edge gap used when resolving `placement` |
24
+ | `width` | `number \| string` | token (320px) | Panel width (number = px, or any CSS length) |
25
+ | `minimized` | `boolean` | `false` | Initial collapsed state (initial-only; runtime via methods) |
26
+ | `draggable` | `boolean` | `true` | Enable header drag-repositioning |
27
+ | `closable` | `boolean` | `false` | Show the close `×` and enable `close()` / Escape |
28
+ | `onClose` | `() => void` | - | Fired by `close()`, the `×` button, and Escape |
29
+ | `closeOnEscape` | `boolean` | `true` | Close on Escape when `closable` |
30
+ | `bringToFrontOnClick` | `boolean` | `true` | Raise above sibling Floats on pointerdown |
31
+ | `storageKey` | `string` | - | Persist `{x, y, minimized}` in `localStorage` (`stuic-float-<key>`) |
32
+ | `margin` | `number` | `0` | Minimum gap kept from every viewport edge while clamping |
33
+ | `minimizeLabel` | `string` | `"Minimize"` | Accessible label for the minimize button |
34
+ | `restoreLabel` | `string` | `"Restore"` | Accessible label when minimized |
35
+ | `closeLabel` | `string` | `"Close"` | Accessible label for the close button |
36
+ | `class` / `classHeader` / `classTitle` / `classActions` / `classBody` | `string` | - | Merged class overrides for each part |
37
+ | `unstyled` | `boolean` | `false` | Drop default styling (still positioned/draggable) |
38
+ | `el` | `HTMLDivElement` | - | Root element reference (bindable) |
39
+
40
+ ## Snippets
41
+
42
+ | Snippet | Description |
43
+ | ---------- | -------------------------------------------------------------------- |
44
+ | `icon` | Leading header content (e.g. a grip/glyph). Drag still works over it |
45
+ | `actions` | Header actions, placed left of the minimize/close buttons |
46
+ | `children` | Body content (hidden when minimized) |
47
+
48
+ ## Methods
49
+
50
+ Accessed via a `bind:this` reference.
51
+
52
+ | Method | Description |
53
+ | -------------------------- | ------------------------------------------------------ |
54
+ | `moveTo(x, y)` | Move to an absolute position (clamped to the viewport) |
55
+ | `moveToPlacement(a, off?)` | Move to a named placement preset |
56
+ | `getPosition()` | `{ x, y }` of the current top-left |
57
+ | `minimize()` / `expand()` | Collapse to / expand from the title bar |
58
+ | `toggleMinimize()` | Toggle collapsed state |
59
+ | `isMinimized()` | Whether currently minimized |
60
+ | `close()` | Fire `onClose` (consumer unmounts) |
61
+ | `bringToFront()` | Raise above sibling Floats |
62
+
63
+ ## Data attributes
64
+
65
+ For CSS targeting: `data-minimized`, `data-dragging`, `data-draggable` (each `"true"`/`"false"`).
66
+
67
+ ## Usage
68
+
69
+ ### Basic tweak panel
70
+
71
+ ```svelte
72
+ <script lang="ts">
73
+ import { Float, Switch } from "stuic";
74
+
75
+ let fov = $state(50);
76
+ let wireframe = $state(false);
77
+ </script>
78
+
79
+ <Float title="Scene settings" placement="top-right" width={280}>
80
+ <label>FOV: {fov}<input type="range" min="10" max="120" bind:value={fov} /></label>
81
+ <Switch bind:checked={wireframe} label="Wireframe" />
82
+ </Float>
83
+ ```
84
+
85
+ ### Imperative control
86
+
87
+ ```svelte
88
+ <script lang="ts">
89
+ let panel: Float = $state()!;
90
+ </script>
91
+
92
+ <button onclick={() => panel.moveToPlacement("bottom-left")}>Dock bottom-left</button>
93
+ <button onclick={() => panel.minimize()}>Minimize</button>
94
+
95
+ <Float bind:this={panel} title="Inspector" x={24} y={24}>…</Float>
96
+ ```
97
+
98
+ ### Persisted position + leading icon + actions
99
+
100
+ ```svelte
101
+ <Float title="Layers" placement="left" storageKey="layers-panel">
102
+ {#snippet icon()}<MyGrip />{/snippet}
103
+ {#snippet actions()}
104
+ <button data-no-drag class="stuic-float-btn" onclick={reset}>↺</button>
105
+ {/snippet}
106
+
107
+ </Float>
108
+ ```
109
+
110
+ `storageKey` remembers `{x, y, minimized}` across reloads. Mark any custom interactive
111
+ controls in the header with `data-no-drag` so they don't start a drag.
112
+
113
+ ### Closable
114
+
115
+ ```svelte
116
+ {#if open}
117
+ <Float title="Hints" closable onClose={() => (open = false)} placement="bottom-right">
118
+
119
+ </Float>
120
+ {/if}
121
+ ```
122
+
123
+ ## CSS Variables
124
+
125
+ Structural tokens (radius/shadow/border-width/transition) resolve via the fallback pattern,
126
+ so global or per-instance overrides work.
127
+
128
+ | Variable | Default | Description |
129
+ | ------------------------------- | ---------------------------------- | -------------------------------------- |
130
+ | `--stuic-float-width` | `320px` | Default panel width |
131
+ | `--stuic-float-z` | `50` | Stacking floor (order is added on top) |
132
+ | `--stuic-float-bg` | `--stuic-color-background` | Panel background |
133
+ | `--stuic-float-text` | `--stuic-color-foreground` | Panel text |
134
+ | `--stuic-float-border-color` | `--stuic-color-border` | Border / header divider color |
135
+ | `--stuic-float-header-bg` | `--stuic-color-surface` | Header background |
136
+ | `--stuic-float-header-text` | `--stuic-color-surface-foreground` | Header text |
137
+ | `--stuic-float-body-max-height` | `70vh` | Body max height before it scrolls |
138
+ | `--stuic-float-btn-bg-hover` | `--stuic-color-muted` | Header button hover background |
139
+ | `--stuic-float-radius` | `--stuic-radius-container` | Corner radius (fallback) |
140
+ | `--stuic-float-shadow` | `--stuic-shadow-overlay` | Drop shadow (fallback) |
141
+
142
+ ```svelte
143
+ <Float style="--stuic-float-header-bg: var(--stuic-color-muted);" title="Custom">…</Float>
144
+ ```
145
+
146
+ ## Notes
147
+
148
+ - **Non-modal**: no backdrop, no focus trap, non-blocking. `role="dialog"` + `aria-modal="false"`.
149
+ - **No portal**: rendered in place with `position: fixed`. A `transform`ed ancestor would
150
+ break `fixed` positioning — mount Float near the top of the tree if that applies.
151
+ - Built on the reusable [`draggable`](../../actions/draggable.svelte.ts) action and the pure
152
+ [`float-utils`](./float-utils.ts) helpers (`resolvePlacement`, `clampToViewport`).
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Returns the next stacking order (a strictly increasing integer).
3
+ *
4
+ * Used both when a `Float` mounts (so later panels start above earlier ones)
5
+ * and on `bringToFront()`.
6
+ *
7
+ * @returns The next order value (>= 1).
8
+ */
9
+ export declare function nextFloatOrder(): number;
10
+ /**
11
+ * Current highest issued order. Mostly useful for tests / debugging.
12
+ *
13
+ * @returns The last value returned by {@link nextFloatOrder} (0 if none yet).
14
+ */
15
+ export declare function currentFloatOrder(): number;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Tiny module-level stacking counter shared by all `Float` instances.
3
+ *
4
+ * Each call returns a strictly increasing integer used as the panel's
5
+ * `z-index` *order* offset (added on top of the `--stuic-float-z` floor in CSS).
6
+ * The most recently raised panel therefore always wins. A plain module
7
+ * singleton is intentional — every import shares the same counter.
8
+ */
9
+ let order = 0;
10
+ /**
11
+ * Returns the next stacking order (a strictly increasing integer).
12
+ *
13
+ * Used both when a `Float` mounts (so later panels start above earlier ones)
14
+ * and on `bringToFront()`.
15
+ *
16
+ * @returns The next order value (>= 1).
17
+ */
18
+ export function nextFloatOrder() {
19
+ return ++order;
20
+ }
21
+ /**
22
+ * Current highest issued order. Mostly useful for tests / debugging.
23
+ *
24
+ * @returns The last value returned by {@link nextFloatOrder} (0 if none yet).
25
+ */
26
+ export function currentFloatOrder() {
27
+ return order;
28
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pure (DOM-free) positioning helpers for the `Float` component.
3
+ *
4
+ * Kept separate from `Float.svelte` so the math can be unit-tested in the fast
5
+ * node test project (`*.test.ts`) without a browser.
6
+ */
7
+ /** A 2D size. */
8
+ export interface FloatSize {
9
+ width: number;
10
+ height: number;
11
+ }
12
+ /** A 2D point (top-left corner, in px). */
13
+ export interface FloatPoint {
14
+ x: number;
15
+ y: number;
16
+ }
17
+ /**
18
+ * Named placement preset, resolved against the viewport (with `Float`'s
19
+ * `offset` applied as a gap from the corresponding edges).
20
+ *
21
+ * - Single axis names (`top`, `left`, ...) center the unspecified axis.
22
+ * - `center` centers both axes.
23
+ */
24
+ export type FloatPlacement = "top-left" | "top" | "top-right" | "left" | "center" | "right" | "bottom-left" | "bottom" | "bottom-right";
25
+ /** All valid placements, handy for demos / iteration. */
26
+ export declare const FLOAT_PLACEMENTS: FloatPlacement[];
27
+ /**
28
+ * Normalizes a scalar-or-`{x,y}` offset into an `{x,y}` pair.
29
+ *
30
+ * @param offset - A single number (applied to both axes) or an explicit pair.
31
+ * @returns The resolved `{x,y}` offset.
32
+ */
33
+ export declare function normalizeOffset(offset: number | Partial<FloatPoint> | undefined, fallback?: number): FloatPoint;
34
+ /**
35
+ * Resolves a named {@link FloatPlacement} to an absolute top-left position so the
36
+ * element sits inside the viewport with `offset` as the edge gap.
37
+ *
38
+ * The returned position is guaranteed to be within `[0, viewport - size]` on
39
+ * each axis (it never needs further clamping).
40
+ *
41
+ * @param placement - The placement preset.
42
+ * @param size - The measured element size.
43
+ * @param viewport - The available viewport size.
44
+ * @param offset - Edge gap (number or `{x,y}`).
45
+ * @returns The resolved top-left `{x,y}`.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * resolvePlacement("top-right", { width: 200, height: 100 },
50
+ * { width: 1000, height: 800 }, { x: 16, y: 16 });
51
+ * // -> { x: 784, y: 16 }
52
+ * ```
53
+ */
54
+ export declare function resolvePlacement(placement: FloatPlacement, size: FloatSize, viewport: FloatSize, offset?: number | Partial<FloatPoint>): FloatPoint;
55
+ /**
56
+ * Clamps a top-left position so the whole element stays inside the viewport
57
+ * (never even partially off-screen), honoring an optional edge `margin`.
58
+ *
59
+ * When the element is larger than the viewport on an axis, it is pinned to the
60
+ * leading edge (`margin`) on that axis.
61
+ *
62
+ * @param pos - The desired top-left position.
63
+ * @param size - The measured element size.
64
+ * @param viewport - The available viewport size.
65
+ * @param margin - Minimum gap kept from every edge (default `0`).
66
+ * @returns The clamped `{x,y}`.
67
+ */
68
+ export declare function clampToViewport(pos: FloatPoint, size: FloatSize, viewport: FloatSize, margin?: number): FloatPoint;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure (DOM-free) positioning helpers for the `Float` component.
3
+ *
4
+ * Kept separate from `Float.svelte` so the math can be unit-tested in the fast
5
+ * node test project (`*.test.ts`) without a browser.
6
+ */
7
+ /** All valid placements, handy for demos / iteration. */
8
+ export const FLOAT_PLACEMENTS = [
9
+ "top-left",
10
+ "top",
11
+ "top-right",
12
+ "left",
13
+ "center",
14
+ "right",
15
+ "bottom-left",
16
+ "bottom",
17
+ "bottom-right",
18
+ ];
19
+ /**
20
+ * Normalizes a scalar-or-`{x,y}` offset into an `{x,y}` pair.
21
+ *
22
+ * @param offset - A single number (applied to both axes) or an explicit pair.
23
+ * @returns The resolved `{x,y}` offset.
24
+ */
25
+ export function normalizeOffset(offset, fallback = 0) {
26
+ if (offset == null)
27
+ return { x: fallback, y: fallback };
28
+ if (typeof offset === "number")
29
+ return { x: offset, y: offset };
30
+ return { x: offset.x ?? fallback, y: offset.y ?? fallback };
31
+ }
32
+ /**
33
+ * Resolves a named {@link FloatPlacement} to an absolute top-left position so the
34
+ * element sits inside the viewport with `offset` as the edge gap.
35
+ *
36
+ * The returned position is guaranteed to be within `[0, viewport - size]` on
37
+ * each axis (it never needs further clamping).
38
+ *
39
+ * @param placement - The placement preset.
40
+ * @param size - The measured element size.
41
+ * @param viewport - The available viewport size.
42
+ * @param offset - Edge gap (number or `{x,y}`).
43
+ * @returns The resolved top-left `{x,y}`.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * resolvePlacement("top-right", { width: 200, height: 100 },
48
+ * { width: 1000, height: 800 }, { x: 16, y: 16 });
49
+ * // -> { x: 784, y: 16 }
50
+ * ```
51
+ */
52
+ export function resolvePlacement(placement, size, viewport, offset = 0) {
53
+ const off = normalizeOffset(offset);
54
+ const maxX = Math.max(0, viewport.width - size.width);
55
+ const maxY = Math.max(0, viewport.height - size.height);
56
+ // horizontal/vertical buckets
57
+ let horiz = "center";
58
+ let vert = "center";
59
+ for (const part of placement.split("-")) {
60
+ if (part === "left" || part === "right")
61
+ horiz = part;
62
+ else if (part === "top" || part === "bottom")
63
+ vert = part;
64
+ // "center" leaves the unspecified axis centered
65
+ }
66
+ const x = horiz === "left" ? off.x : horiz === "right" ? maxX - off.x : maxX / 2;
67
+ const y = vert === "top" ? off.y : vert === "bottom" ? maxY - off.y : maxY / 2;
68
+ // guard against offsets larger than the available space
69
+ return {
70
+ x: Math.min(Math.max(0, x), maxX),
71
+ y: Math.min(Math.max(0, y), maxY),
72
+ };
73
+ }
74
+ /**
75
+ * Clamps a top-left position so the whole element stays inside the viewport
76
+ * (never even partially off-screen), honoring an optional edge `margin`.
77
+ *
78
+ * When the element is larger than the viewport on an axis, it is pinned to the
79
+ * leading edge (`margin`) on that axis.
80
+ *
81
+ * @param pos - The desired top-left position.
82
+ * @param size - The measured element size.
83
+ * @param viewport - The available viewport size.
84
+ * @param margin - Minimum gap kept from every edge (default `0`).
85
+ * @returns The clamped `{x,y}`.
86
+ */
87
+ export function clampToViewport(pos, size, viewport, margin = 0) {
88
+ const maxX = Math.max(margin, viewport.width - size.width - margin);
89
+ const maxY = Math.max(margin, viewport.height - size.height - margin);
90
+ return {
91
+ x: Math.min(Math.max(margin, pos.x), maxX),
92
+ y: Math.min(Math.max(margin, pos.y), maxY),
93
+ };
94
+ }
@@ -0,0 +1,182 @@
1
+ /* ============================================================================
2
+ FLOAT COMPONENT TOKENS
3
+ Override globally: :root { --stuic-float-width: 360px; }
4
+ Override locally: <Float style="--stuic-float-header-bg: red;" />
5
+ Structural tokens (radius/shadow/border-width/transition) are resolved via the
6
+ fallback pattern at the usage site, so per-instance overrides keep working.
7
+ ============================================================================ */
8
+
9
+ :root {
10
+ /* Surface */
11
+ --stuic-float-bg: var(--stuic-color-background);
12
+ --stuic-float-text: var(--stuic-color-foreground);
13
+ --stuic-float-border-color: var(--stuic-color-border);
14
+
15
+ /* Stacking: floor + per-instance order offset (set inline by the component) */
16
+ --stuic-float-z: 50;
17
+
18
+ /* Sizing */
19
+ --stuic-float-width: 320px;
20
+ --stuic-float-body-max-height: 70vh;
21
+
22
+ /* Header */
23
+ --stuic-float-header-bg: var(--stuic-color-surface, var(--stuic-color-muted));
24
+ --stuic-float-header-text: var(
25
+ --stuic-color-surface-foreground,
26
+ var(--stuic-color-foreground)
27
+ );
28
+ --stuic-float-header-padding-x: 0.5rem;
29
+ --stuic-float-header-padding-y: 0.25rem;
30
+ --stuic-float-header-gap: 0.5rem;
31
+ --stuic-float-header-font-weight: var(--font-weight-medium);
32
+
33
+ /* Body */
34
+ --stuic-float-body-padding-x: 0.75rem;
35
+ --stuic-float-body-padding-y: 0.75rem;
36
+
37
+ /* Header buttons */
38
+ --stuic-float-btn-size: 1.75rem;
39
+ --stuic-float-btn-color: var(--stuic-color-muted-foreground);
40
+ --stuic-float-btn-color-hover: var(--stuic-color-foreground);
41
+ --stuic-float-btn-bg-hover: var(--stuic-color-muted);
42
+
43
+ /* Focus ring */
44
+ --stuic-float-ring-width: 2px;
45
+ --stuic-float-ring-color: var(--stuic-color-ring);
46
+ }
47
+
48
+ @layer components {
49
+ /* ============================================================================
50
+ ROOT
51
+ ============================================================================ */
52
+
53
+ .stuic-float {
54
+ position: fixed;
55
+ display: flex;
56
+ flex-direction: column;
57
+ width: var(--stuic-float-width);
58
+ max-width: 100vw;
59
+ z-index: calc(var(--stuic-float-z) + var(--stuic-float-z-order, 0));
60
+
61
+ background: var(--stuic-float-bg);
62
+ color: var(--stuic-float-text);
63
+ border: var(--stuic-float-border-width, var(--stuic-border-width)) solid
64
+ var(--stuic-float-border-color);
65
+ border-radius: var(--stuic-float-radius, var(--stuic-radius-container));
66
+ box-shadow: var(--stuic-float-shadow, var(--stuic-shadow-overlay));
67
+ overflow: hidden; /* clip header/body to the rounded corners */
68
+ }
69
+
70
+ /* ============================================================================
71
+ HEADER (drag handle)
72
+ ============================================================================ */
73
+
74
+ .stuic-float-header {
75
+ display: flex;
76
+ align-items: center;
77
+ gap: var(--stuic-float-header-gap);
78
+ padding: var(--stuic-float-header-padding-y) var(--stuic-float-header-padding-x);
79
+ background: var(--stuic-float-header-bg);
80
+ color: var(--stuic-float-header-text);
81
+ border-bottom: var(--stuic-float-border-width, var(--stuic-border-width)) solid
82
+ var(--stuic-float-border-color);
83
+ user-select: none;
84
+ touch-action: none; /* let the drag own the gesture on touch devices */
85
+ }
86
+
87
+ .stuic-float[data-draggable="true"] > .stuic-float-header {
88
+ cursor: grab;
89
+ }
90
+
91
+ .stuic-float[data-dragging="true"] > .stuic-float-header {
92
+ cursor: grabbing;
93
+ }
94
+
95
+ .stuic-float-icon {
96
+ display: inline-flex;
97
+ align-items: center;
98
+ flex-shrink: 0;
99
+ }
100
+
101
+ .stuic-float-title {
102
+ flex: 1 1 auto;
103
+ min-width: 0;
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ white-space: nowrap;
107
+ font-weight: var(--stuic-float-header-font-weight);
108
+ }
109
+
110
+ .stuic-float-actions {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 0.125rem;
114
+ flex-shrink: 0;
115
+ }
116
+
117
+ /* ============================================================================
118
+ HEADER BUTTONS
119
+ ============================================================================ */
120
+
121
+ .stuic-float-btn {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ width: var(--stuic-float-btn-size);
126
+ height: var(--stuic-float-btn-size);
127
+ border: none;
128
+ border-radius: var(--stuic-float-btn-radius, var(--stuic-radius));
129
+ background: transparent;
130
+ color: var(--stuic-float-btn-color);
131
+ cursor: pointer;
132
+ transition:
133
+ background var(--stuic-float-transition, var(--stuic-transition)),
134
+ color var(--stuic-float-transition, var(--stuic-transition));
135
+ }
136
+
137
+ .stuic-float-btn:hover {
138
+ background: var(--stuic-float-btn-bg-hover);
139
+ color: var(--stuic-float-btn-color-hover);
140
+ }
141
+
142
+ .stuic-float-btn:focus-visible {
143
+ outline: var(--stuic-float-ring-width) solid var(--stuic-float-ring-color);
144
+ outline-offset: -2px;
145
+ }
146
+
147
+ .stuic-float-chevron {
148
+ display: inline-flex;
149
+ transition: transform var(--stuic-float-transition, var(--stuic-transition));
150
+ }
151
+
152
+ /* expanded -> chevron points up ("collapse"); minimized -> points down ("expand") */
153
+ .stuic-float[data-minimized="false"] .stuic-float-chevron {
154
+ transform: rotate(180deg);
155
+ }
156
+
157
+ /* ============================================================================
158
+ BODY (grid-based collapse, mirrors Accordion)
159
+ ============================================================================ */
160
+
161
+ .stuic-float-body-wrap {
162
+ display: grid;
163
+ grid-template-rows: 1fr;
164
+ transition: grid-template-rows var(--stuic-float-transition, var(--stuic-transition));
165
+ }
166
+
167
+ .stuic-float[data-minimized="true"] > .stuic-float-body-wrap {
168
+ grid-template-rows: 0fr;
169
+ }
170
+
171
+ .stuic-float-body-inner {
172
+ overflow: hidden;
173
+ min-height: 0;
174
+ }
175
+
176
+ .stuic-float-body {
177
+ max-height: var(--stuic-float-body-max-height);
178
+ overflow: auto;
179
+ scrollbar-width: thin;
180
+ padding: var(--stuic-float-body-padding-y) var(--stuic-float-body-padding-x);
181
+ }
182
+ }
@@ -0,0 +1,2 @@
1
+ export { default as Float, type Props as FloatProps } from "./Float.svelte";
2
+ export { type FloatPlacement, type FloatPoint, type FloatSize, FLOAT_PLACEMENTS, resolvePlacement, clampToViewport, normalizeOffset, } from "./float-utils.js";
@@ -0,0 +1,2 @@
1
+ export { default as Float } from "./Float.svelte";
2
+ export { FLOAT_PLACEMENTS, resolvePlacement, clampToViewport, normalizeOffset, } from "./float-utils.js";
package/dist/index.css CHANGED
@@ -76,6 +76,7 @@ In practice:
76
76
  @import "./components/DismissibleMessage/index.css";
77
77
  @import "./components/DropdownMenu/index.css";
78
78
  @import "./components/EmailVerifyForm/index.css";
79
+ @import "./components/Float/index.css";
79
80
  @import "./components/H/index.css";
80
81
  @import "./components/Header/index.css";
81
82
  @import "./components/ImageCycler/index.css";
package/dist/index.d.ts CHANGED
@@ -44,6 +44,7 @@ export * from "./components/DismissibleMessage/index.js";
44
44
  export * from "./components/Drawer/index.js";
45
45
  export * from "./components/DropdownMenu/index.js";
46
46
  export * from "./components/EmailVerifyForm/index.js";
47
+ export * from "./components/Float/index.js";
47
48
  export * from "./components/H/index.js";
48
49
  export * from "./components/Header/index.js";
49
50
  export * from "./components/ImageCycler/index.js";
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ export * from "./components/DismissibleMessage/index.js";
45
45
  export * from "./components/Drawer/index.js";
46
46
  export * from "./components/DropdownMenu/index.js";
47
47
  export * from "./components/EmailVerifyForm/index.js";
48
+ export * from "./components/Float/index.js";
48
49
  export * from "./components/H/index.js";
49
50
  export * from "./components/Header/index.js";
50
51
  export * from "./components/ImageCycler/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.120.0",
3
+ "version": "3.121.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -127,7 +127,7 @@
127
127
  "@codemirror/language": "^6.12.3",
128
128
  "@codemirror/language-data": "^6.5.2",
129
129
  "@codemirror/state": "^6.6.0",
130
- "@codemirror/view": "^6.43.0",
130
+ "@codemirror/view": "^6.43.1",
131
131
  "@eslint/js": "^9.39.4",
132
132
  "@marianmeres/random-human-readable": "^1.10.2",
133
133
  "@milkdown/core": "^7.21.2",
@@ -140,37 +140,37 @@
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.64.0",
143
+ "@sveltejs/kit": "^2.65.1",
144
144
  "@sveltejs/package": "^2.5.8",
145
145
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
146
- "@tailwindcss/cli": "^4.3.0",
146
+ "@tailwindcss/cli": "^4.3.1",
147
147
  "@tailwindcss/forms": "^0.5.11",
148
148
  "@tailwindcss/typography": "^0.5.20",
149
- "@tailwindcss/vite": "^4.3.0",
150
- "@types/node": "^25.9.2",
151
- "@vitest/browser-playwright": "^4.1.8",
149
+ "@tailwindcss/vite": "^4.3.1",
150
+ "@types/node": "^25.9.3",
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
155
  "playwright": "^1.60.0",
156
- "prettier": "^3.8.3",
156
+ "prettier": "^3.8.4",
157
157
  "prettier-plugin-svelte": "^3.5.2",
158
158
  "publint": "^0.3.21",
159
159
  "svelte": "^5.56.3",
160
160
  "svelte-check": "^4.6.0",
161
- "tailwindcss": "^4.3.0",
161
+ "tailwindcss": "^4.3.1",
162
162
  "tsx": "^4.22.4",
163
163
  "typescript": "^5.9.3",
164
164
  "typescript-eslint": "^8.61.0",
165
165
  "vite": "^7.3.5",
166
- "vitest": "^4.1.8",
166
+ "vitest": "^4.1.9",
167
167
  "vitest-browser-svelte": "^2.1.1"
168
168
  },
169
169
  "dependencies": {
170
170
  "@marianmeres/clog": "^3.21.0",
171
171
  "@marianmeres/countries": "^1.0.1",
172
172
  "@marianmeres/cron": "^2.0.1",
173
- "@marianmeres/design-tokens": "^1.8.0",
173
+ "@marianmeres/design-tokens": "^1.9.0",
174
174
  "@marianmeres/icons-fns": "^5.0.0",
175
175
  "@marianmeres/item-collection": "^1.4.2",
176
176
  "@marianmeres/paging-store": "^2.1.1",