@@ -26,9 +26,9 @@
Window Management
for Svelte
-
+
- A draggable and resizable window manager that works seamlessly in your projects.
+ A draggable and resizable window manager that works seamlessly in your projects.
Works in scroll areas, overflow situations, with intelligent window stacking.
diff --git a/src/lib/homepage/Installation.svelte b/src/homepage/Installation.svelte
similarity index 95%
rename from src/lib/homepage/Installation.svelte
rename to src/homepage/Installation.svelte
index 2c32452..81613f4 100644
--- a/src/lib/homepage/Installation.svelte
+++ b/src/homepage/Installation.svelte
@@ -1,10 +1,10 @@
+
+
+
+
diff --git a/src/lib/BottomResize.svelte b/src/lib/BottomResize.svelte
index e3e1441..4c9b4d0 100644
--- a/src/lib/BottomResize.svelte
+++ b/src/lib/BottomResize.svelte
@@ -1,6 +1,7 @@
-
+
diff --git a/src/lib/LeftResize.svelte b/src/lib/LeftResize.svelte
index 7261b0e..1d0e56c 100644
--- a/src/lib/LeftResize.svelte
+++ b/src/lib/LeftResize.svelte
@@ -1,6 +1,7 @@
-
+
diff --git a/src/lib/TopResize.svelte b/src/lib/TopResize.svelte
index f12f02e..c490916 100644
--- a/src/lib/TopResize.svelte
+++ b/src/lib/TopResize.svelte
@@ -1,6 +1,7 @@
-
+
diff --git a/src/lib/Window.svelte b/src/lib/Window.svelte
index 7abda4f..fcb8268 100644
--- a/src/lib/Window.svelte
+++ b/src/lib/Window.svelte
@@ -1,10 +1,18 @@
diff --git a/src/routes/docs/+page.svelte b/src/routes/docs/+page.svelte
index 0e1b63b..d51f7e5 100644
--- a/src/routes/docs/+page.svelte
+++ b/src/routes/docs/+page.svelte
@@ -1,22 +1,282 @@
+
+
Documentation | svelte-windows
+
-
- Not done yet 😢
-
-
-
\ No newline at end of file
+
+
+
+
Documentation
+
svelte-windows API guide
+
+ Build desktop-like interfaces in Svelte with drag, resize, stacking, mobile touch handling, and iframe-safe focus behavior.
+
+
+
+
+
+
+
+
+
+
+
+
Quick start
+
Install the package, then place one or more windows inside a manager.
+
+ {installCommand}
+
+
+
+
Core model
+
+ WindowManager sets up global move/up handlers and provides context.
+ Window registers itself, manages z-order, and renders drag/resize handles.
+ MouseContext multiplexes movement to one active drag/resize target.
+ WindowContext tracks active window and stacking history.
+
+
+
+
+
+
+
+
Basic usage
+
+ Use snippet children to receive the manager context. Define draggable hit areas with windowDragRegions.
+
+
+
Example.svelte
+
{quickStartExample}
+
+
+
+
+
+
+
WindowManager props
+
+ You can pass your own context instances when coordinating multiple managers or integrating external window state.
+
+
+
+
+
+ | Prop |
+ Type |
+ Default |
+ Required |
+ Description |
+
+
+
+ {#each windowManagerProps as prop}
+
+ | {prop.name} |
+ {prop.type} |
+ {prop.default} |
+ {prop.required} |
+ {prop.description} |
+
+ {/each}
+
+
+
+
+
+
+
+
+
WindowDragConfig
+
+ Each drag region requires width and height and can be positioned using either
+ top or bottom plus either left or right. You can optionally pass
+ color to visualize a drag region while designing custom chrome.
+
+
+ - Vertical anchor: choose exactly one of
top or bottom.
+ - Horizontal anchor: choose exactly one of
left or right.
+ - Size keys
width and height are required strings.
+ color is optional and useful for debugging hit zones.
+
+
+
WindowDragConfig example
+
{dragConfigExample}
+
+
+
+
+
+
+
Window props
+
+ Dimensions and position props are bindable and use px strings. You can style both the shell and content layers.
+
+
+
+
+
+ | Prop |
+ Type |
+ Default |
+ Required |
+ Description |
+
+
+
+ {#each windowProps as prop}
+
+ | {prop.name} |
+ {prop.type} |
+ {prop.default} |
+ {prop.required} |
+ {prop.description} |
+
+ {/each}
+
+
+
+
+
+
+
+
+
Lifecycle callbacks
+
+ React to active state, drag events, and resize events. Useful for syncing layout state to your own stores.
+
+
+ {#each lifecycleEvents as event}
+
+
{event.name}
+
Payload: {event.payload}
+
{event.description}
+
+ {/each}
+
+
+
Callback example
+
{callbackExample}
+
+
+
+
+
+
+
Styling and behavior
+
+
+
Drag regions
+
+ Dragging is opt-in. Define one or more absolute regions to control where users can grab the window.
+
+
+
+
Resize handles
+
+ All four edges and four corners are supported. Touch and mouse are both wired through the same manager.
+
+
+
+
Shell vs content
+
+ Use outerStyle/outerClassName for shadows and chrome, and innerStyle/innerClassName for content.
+
+
+
+
Window stacking
+
+ Active windows are promoted to the front automatically. Iframe focus is detected and translated into window activation.
+
+
+
+
+
+
+
+
+
Exports
+
+
+ WindowManager, Window
+ MouseContext, WindowContext
+ INACTIVE_MOUSE_ID
+ WindowDragConfig, WindowPosition, WindowDimensions, ActualWindowProps
+
+
+
+
+
\ No newline at end of file
diff --git a/src/routes/playground/+page.svelte b/src/routes/playground/+page.svelte
index cefd8a3..edb76e3 100644
--- a/src/routes/playground/+page.svelte
+++ b/src/routes/playground/+page.svelte
@@ -6,7 +6,6 @@
`;
- let showBox = $state(true);
- let boxWidth = $state("400px");
- let boxHeight = $state("300px");
- let boxTop = $state("0px");
- let boxLeft = $state("0px");
@@ -40,7 +34,7 @@
id="setter"
{context}
windowDragRegions={[{width:"100%",height:"40px",top:"0px",left:"0px"}]}
- style="--boxShadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px;--borderRadius: 10px;"
+ outerStyle="box-shadow: rgba(0, 0, 0, 0.25) 0px 54px 55px, rgba(0, 0, 0, 0.12) 0px -12px 30px, rgba(0, 0, 0, 0.12) 0px 4px 6px, rgba(0, 0, 0, 0.17) 0px 12px 13px, rgba(0, 0, 0, 0.09) 0px -3px 5px;border-radius: 10px;"
>
diff --git a/src/lib/tailwindutils.ts b/src/tailwindutils.ts
similarity index 98%
rename from src/lib/tailwindutils.ts
rename to src/tailwindutils.ts
index 08501bf..3200be2 100644
--- a/src/lib/tailwindutils.ts
+++ b/src/tailwindutils.ts
@@ -3,4 +3,4 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
-}
\ No newline at end of file
+}
diff --git a/src/test/harnesses/WindowDraggerHarness.svelte b/src/test/harnesses/WindowDraggerHarness.svelte
new file mode 100644
index 0000000..20f62ea
--- /dev/null
+++ b/src/test/harnesses/WindowDraggerHarness.svelte
@@ -0,0 +1,35 @@
+
+
+
{}}
+ bind:top
+ bind:left
+/>
+
+
diff --git a/src/test/harnesses/WindowDraggerHarness.svelte.d.ts b/src/test/harnesses/WindowDraggerHarness.svelte.d.ts
new file mode 100644
index 0000000..e24851c
--- /dev/null
+++ b/src/test/harnesses/WindowDraggerHarness.svelte.d.ts
@@ -0,0 +1,12 @@
+import type { MouseContext, WindowDragConfig } from "../../lib/utils.js";
+type $$ComponentProps = {
+ mouseContext: MouseContext;
+ parentWindow: HTMLElement;
+ desktop: HTMLElement;
+ dragConfig: WindowDragConfig;
+ active?: boolean;
+};
+declare const WindowDraggerHarness: import("svelte").Component<$$ComponentProps, {}, "">;
+type WindowDraggerHarness = ReturnType;
+export default WindowDraggerHarness;
+//# sourceMappingURL=WindowDraggerHarness.svelte.d.ts.map
\ No newline at end of file
diff --git a/src/test/harnesses/WindowDraggerHarness.svelte.d.ts.map b/src/test/harnesses/WindowDraggerHarness.svelte.d.ts.map
new file mode 100644
index 0000000..6c1faa3
--- /dev/null
+++ b/src/test/harnesses/WindowDraggerHarness.svelte.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"WindowDraggerHarness.svelte.d.ts","sourceRoot":"","sources":["WindowDraggerHarness.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAExE,KAAK,gBAAgB,GAAI;IACxB,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,WAAW,CAAC;IAC1B,OAAO,EAAE,WAAW,CAAC;IACrB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,MAAM,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAuBH,QAAA,MAAM,oBAAoB,sDAAwC,CAAC;AACnE,KAAK,oBAAoB,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC;AACpE,eAAe,oBAAoB,CAAC"}
\ No newline at end of file
diff --git a/src/test/lib/Window.svelte.test.ts b/src/test/lib/Window.svelte.test.ts
new file mode 100644
index 0000000..746efe7
--- /dev/null
+++ b/src/test/lib/Window.svelte.test.ts
@@ -0,0 +1,86 @@
+import { render } from "@testing-library/svelte";
+import { tick } from "svelte";
+import { describe, expect, it } from "vitest";
+import Window from "../../lib/Window.svelte";
+import { MouseContext, WindowContext } from "../../lib/utils.js";
+
+function createRect(left: number, top: number, width: number, height: number): DOMRect {
+ return {
+ x: left,
+ y: top,
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ toJSON: () => ({})
+ } as DOMRect;
+}
+
+function createContext() {
+ const desktop = document.createElement("div");
+ Object.defineProperty(desktop, "getBoundingClientRect", {
+ value: () => createRect(0, 0, 900, 700)
+ });
+
+ return {
+ mouseContext: new MouseContext(),
+ windowContext: new WindowContext(),
+ desktop
+ };
+}
+
+describe("Window.svelte", () => {
+ it("renders configured drag regions", async () => {
+ const context = createContext();
+ const { container } = render(Window, {
+ props: {
+ id: "win",
+ context,
+ windowDragRegions: [{ width: "100%", height: "40px", top: "0px", left: "0px" }]
+ }
+ });
+
+ await tick();
+ expect(container.querySelector("#windowDraggerwin0")).toBeInTheDocument();
+ });
+
+ it("hides resize handles when resizable is false", async () => {
+ const context = createContext();
+ const { container } = render(Window, {
+ props: {
+ id: "readonly",
+ context,
+ windowDragRegions: [{ width: "100%", height: "40px", top: "0px", left: "0px" }],
+ resizable: false
+ }
+ });
+
+ await tick();
+ expect(container.querySelector("#windowresizereadonlytop")).not.toBeInTheDocument();
+ expect(container.querySelector("#windowresizereadonlybottomRight")).not.toBeInTheDocument();
+ });
+
+ it("renders both edge and corner handles when resizable", async () => {
+ const context = createContext();
+ const { container } = render(Window, {
+ props: {
+ id: "full",
+ context,
+ windowDragRegions: [{ width: "100%", height: "40px", top: "0px", left: "0px" }],
+ resizable: true
+ }
+ });
+
+ await tick();
+ expect(container.querySelector("#windowresizefulltop")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefullright")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefullbottom")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefullleft")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefulltopLeft")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefulltopRight")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefullbottomLeft")).toBeInTheDocument();
+ expect(container.querySelector("#windowresizefullbottomRight")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/lib/WindowDragger.svelte.test.ts b/src/test/lib/WindowDragger.svelte.test.ts
new file mode 100644
index 0000000..7bd9669
--- /dev/null
+++ b/src/test/lib/WindowDragger.svelte.test.ts
@@ -0,0 +1,54 @@
+import { fireEvent, render, screen } from "@testing-library/svelte";
+import { tick } from "svelte";
+import { describe, expect, it, vi } from "vitest";
+import WindowDraggerHarness from "../harnesses/WindowDraggerHarness.svelte";
+import { MouseContext } from "../../lib/utils.js";
+
+function createRect(left: number, top: number, width: number, height: number): DOMRect {
+ return {
+ x: left,
+ y: top,
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ toJSON: () => ({})
+ } as DOMRect;
+}
+
+describe("WindowDragger.svelte", () => {
+ it("sets active dragger id on mousedown and updates bound coordinates on move", async () => {
+ const mouseContext = new MouseContext();
+ const activeSpy = vi.fn();
+ mouseContext.subscribeActiveMouseSubscribers(activeSpy);
+
+ const parentWindow = document.createElement("div");
+ const desktop = document.createElement("div");
+
+ Object.defineProperty(parentWindow, "getBoundingClientRect", {
+ value: () => createRect(100, 100, 200, 150)
+ });
+ Object.defineProperty(desktop, "getBoundingClientRect", {
+ value: () => createRect(0, 0, 800, 600)
+ });
+
+ const dragConfig = { width: "100%", height: "40px", top: "0px", left: "0px" } as const;
+
+ const { container } = render(WindowDraggerHarness, {
+ props: { mouseContext, parentWindow, desktop, dragConfig }
+ });
+
+ const dragger = container.querySelector("#dragger-test");
+ expect(dragger).toBeInTheDocument();
+
+ await fireEvent.mouseDown(dragger!, { clientX: 150, clientY: 140 });
+ expect(activeSpy).toHaveBeenLastCalledWith("dragger-test");
+
+ mouseContext.mouseMoving({ clientX: 300, clientY: 260 } as MouseEvent);
+ await tick();
+
+ expect(screen.getByTestId("coords")).toHaveTextContent("220px,250px");
+ });
+});
diff --git a/src/test/lib/utils.test.ts b/src/test/lib/utils.test.ts
new file mode 100644
index 0000000..41c344b
--- /dev/null
+++ b/src/test/lib/utils.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it, vi } from "vitest";
+import { INACTIVE_MOUSE_ID, MouseContext, WindowContext } from "../../lib/utils.js";
+
+describe("MouseContext", () => {
+ it("notifies active subscribers and broadcasts inactive sentinel on mouse up", () => {
+ const context = new MouseContext();
+ const activeSpy = vi.fn();
+ context.subscribeActiveMouseSubscribers(activeSpy);
+
+ context.setActiveMouseTarget("windowDraggeralpha0");
+ context.mouseIsUp();
+
+ expect(activeSpy).toHaveBeenNthCalledWith(1, "windowDraggeralpha0");
+ expect(activeSpy).toHaveBeenLastCalledWith(INACTIVE_MOUSE_ID);
+ });
+
+ it("dispatches mouse movement to the active responder", () => {
+ const context = new MouseContext();
+ const moveSpy = vi.fn();
+ context.addMouseMoveResponder("windowDraggeralpha0", moveSpy);
+ context.setActiveMouseTarget("windowDraggeralpha0");
+
+ context.mouseMoving({ clientX: 240, clientY: 360 } as MouseEvent);
+
+ expect(moveSpy).toHaveBeenCalledTimes(1);
+ expect(moveSpy).toHaveBeenCalledWith(expect.objectContaining({ clientX: 240, clientY: 360 }));
+ });
+
+ it("dispatches touch movement and prevents scroll while dragging", () => {
+ const context = new MouseContext();
+ const moveSpy = vi.fn();
+ const preventDefault = vi.fn();
+ context.addMouseMoveResponder("windowresizealphaRight", moveSpy);
+ context.setActiveMouseTarget("windowresizealphaRight");
+
+ context.touchMoving({
+ touches: [{ clientX: 123, clientY: 456 }],
+ preventDefault
+ } as unknown as TouchEvent);
+
+ expect(preventDefault).toHaveBeenCalledTimes(1);
+ expect(moveSpy).toHaveBeenCalledWith({ clientX: 123, clientY: 456 });
+ });
+});
+
+describe("WindowContext", () => {
+ it("registers windows and keeps active stack order", () => {
+ const context = new WindowContext();
+ const windowA = vi.fn();
+ const windowB = vi.fn();
+
+ const unregisterA = context.registerWindow("window-a", windowA);
+ context.registerWindow("window-b", windowB);
+
+ expect(context.currentActiveWindow).toBe("window-b");
+ expect(context.orderOfActiveWindows).toEqual(["window-a", "window-b"]);
+
+ context.setActiveWindow("window-a");
+
+ expect(context.currentActiveWindow).toBe("window-a");
+ expect(context.orderOfActiveWindows).toEqual(["window-b", "window-a"]);
+
+ unregisterA();
+ expect(context.currentActiveWindow).toBe("window-b");
+ expect(context.orderOfActiveWindows).toEqual(["window-b"]);
+ });
+
+ it("removes iframe listeners from the correct map", () => {
+ const context = new WindowContext();
+ const activeSpy = vi.fn();
+ context.subscribeActiveWindow(activeSpy);
+ activeSpy.mockClear();
+
+ const iframeSpy = vi.fn();
+ const unsubscribeIframe = context.iframeWindowClickedSubscribe(iframeSpy);
+ unsubscribeIframe();
+
+ context.setActiveWindow("window-a", true);
+
+ expect(iframeSpy).not.toHaveBeenCalled();
+ expect(activeSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..f149f27
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/static/style.css b/static/style.css
deleted file mode 100644
index 84ed252..0000000
--- a/static/style.css
+++ /dev/null
@@ -1,38 +0,0 @@
-/* *, *::before, *::after {
- padding: 0;
- margin: 0;
- box-sizing: border-box;
- text-decoration: none;
-}
-
-body {
- width: 100vw;
- height: 100vh;
- height: 100dvh;
- background: none;
- color: white;
- font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;
- -webkit-font-smoothing: antialiased;
-} */
-
- /* ::-webkit-scrollbar-track
- {
- background-color: rgba(0, 0, 0, 0);
- }
-
- ::-webkit-scrollbar
- {
- width: 5px;
- background-color: rgba(0,0,0,0);
- }
-
- ::-webkit-scrollbar-thumb
- {
- background-color: rgba(255, 255, 255, 0.315);
- }
-
-
- html {
- font-size: 10px;
- } */
-
\ No newline at end of file
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..7912e95
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { sveltekit } from "@sveltejs/kit/vite";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ resolve: {
+ conditions: ["browser"]
+ },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["src/test/setup.ts"],
+ include: ["src/**/*.{test,spec}.{js,ts}"]
+ }
+});