From 44faba92951f5647764e1e27fbf9dd9eb9e70b46 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sat, 22 Aug 2026 19:11:18 +0530 Subject: [PATCH 1/7] fix: add drag to snap to floating button --- docs/content/docs/api-reference/devtools.mdx | 1 - .../docs/openui-lang/developer-tools.mdx | 4 +- packages/devtools/README.md | 3 +- packages/devtools/src/OpenUIDevtools.test.ts | 109 +++++++++++++ packages/devtools/src/OpenUIDevtools.tsx | 54 +++++-- packages/devtools/src/lib/index.ts | 2 + packages/devtools/src/lib/position.ts | 58 +++++++ .../devtools/src/lib/useDevtoolsConfig.ts | 18 ++- packages/devtools/src/lib/useSnapCorner.ts | 151 ++++++++++++++++++ 9 files changed, 374 insertions(+), 26 deletions(-) create mode 100644 packages/devtools/src/lib/position.ts create mode 100644 packages/devtools/src/lib/useSnapCorner.ts diff --git a/docs/content/docs/api-reference/devtools.mdx b/docs/content/docs/api-reference/devtools.mdx index 1377f6336..b46bf8cf5 100644 --- a/docs/content/docs/api-reference/devtools.mdx +++ b/docs/content/docs/api-reference/devtools.mdx @@ -51,7 +51,6 @@ function App() { | Prop | Default | Description | | ----------------- | ---------------- | ---------------------------------------------------------------- | | `enabled` | dev-only | Force the widget on/off. | -| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | | `errorsOnly` | `true` | Capture only error/warning events, or every event. | | `autoOpenOnError` | `true` | Initial state of the drawer's "auto-open on error" checkbox. | diff --git a/docs/content/docs/openui-lang/developer-tools.mdx b/docs/content/docs/openui-lang/developer-tools.mdx index ee0c9be2f..e0e9b3153 100644 --- a/docs/content/docs/openui-lang/developer-tools.mdx +++ b/docs/content/docs/openui-lang/developer-tools.mdx @@ -29,7 +29,7 @@ yarn add -D @openuidev/devtools npm install -D @openuidev/devtools ``` -Then mount it once, anywhere in the tree. A manual instance replaces the auto-mounted one, so you can set the corner, theme, or other props: +Then mount it once, anywhere in the tree. A manual instance replaces the auto-mounted one, so you can set theme or other props. ```tsx import { OpenUIDevtools } from "@openuidev/devtools"; @@ -38,7 +38,7 @@ export function App() { return ( <> {/* your app */} - + ); } diff --git a/packages/devtools/README.md b/packages/devtools/README.md index 517938bd3..833e81567 100644 --- a/packages/devtools/README.md +++ b/packages/devtools/README.md @@ -19,6 +19,8 @@ function App() { The widget renders nothing in production builds (`NODE_ENV === "production"`) unless `enabled` is passed explicitly. +Drag the floating button to snap it to a different corner, like the Next.js indicator. The corner is stored in `localStorage` and restored on the next visit (default `bottom-right`). The `position` prop is deprecated and ignored. + `@openuidev/react-lang` ships with this package and auto-mounts the widget in development — no manual `` needed. Mounting it manually still works (e.g. to customize props): only one instance ever renders, and a manually mounted instance takes precedence over the auto-mounted one. In development, `createLibrary()` registers the live library with the widget. A stream event's **Debug** button opens **OpenUI Debug** in its own tray — an editor against that library (host CSS included), with Render / Validation / Tree / JSON / Stream panels and simulated stream playback. @@ -30,7 +32,6 @@ Debug renders through the host's own `Renderer`. Its previews stay off the event | Prop | Default | Description | | ----------------- | ---------------- | ---------------------------------------------------------------- | | `enabled` | dev-only | Force the widget on/off. | -| `position` | `"bottom-right"` | Corner for the toggle button: `top-left`/`top-right`/`bottom-*`. | | `maxEvents` | `50` | How many events to keep; oldest are dropped first. | | `errorsOnly` | `true` | Capture only error/warning events, or all. | | `autoOpenOnError` | `true` | Initial state of the "auto-open on error" setting. | diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index f2492ec26..7c8017e38 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -4,6 +4,18 @@ import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OpenUIDevtools, type OpenUIDevtoolsProps } from "./index"; +import { nearestCorner } from "./lib/position"; + +if (typeof globalThis.PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + pointerId: number; + constructor(type: string, init: MouseEventInit & { pointerId?: number } = {}) { + super(type, init); + this.pointerId = init.pointerId ?? 0; + } + } + Object.assign(globalThis, { PointerEvent: PointerEventPolyfill }); +} vi.mock("@openuidev/react-lang", async () => { const { createElement: el } = await import("react"); @@ -729,4 +741,101 @@ describe("OpenUIDevtools", () => { expect(container.textContent).toContain("Allow popups for this origin"); open.mockRestore(); }); + + it("defaults the toggle to the bottom-right corner", () => { + render({ enabled: true }); + const wrap = toggle().parentElement!; + expect(wrap.style.bottom).toBe("16px"); + expect(wrap.style.right).toBe("16px"); + }); + + it("restores a snapped corner from a previous session", () => { + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true }); + const wrap = toggle().parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + }); + + it("ignores a deprecated position prop in favor of the stored corner", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.localStorage.setItem("openui.devtools.config", JSON.stringify({ position: "top-left" })); + render({ enabled: true, position: "top-right" }); + const wrap = toggle().parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("`position` prop is deprecated")); + warn.mockRestore(); + }); + + it("snaps the toggle to the nearest corner on drag and remembers it", () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 }); + render({ enabled: true }); + + const button = toggle(); + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + x: 968, + y: 712, + left: 968, + top: 712, + right: 1008, + bottom: 752, + width: 40, + height: 40, + toJSON: () => ({}), + }); + + act(() => { + button.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 1, + button: 0, + clientX: 988, + clientY: 732, + }), + ); + button.dispatchEvent( + new PointerEvent("pointermove", { + bubbles: true, + pointerId: 1, + clientX: 40, + clientY: 40, + }), + ); + button.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + pointerId: 1, + clientX: 40, + clientY: 40, + }), + ); + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(button.getAttribute("aria-expanded")).toBe("false"); + const wrap = button.parentElement!; + expect(wrap.style.top).toBe("16px"); + expect(wrap.style.left).toBe("16px"); + expect(JSON.parse(window.localStorage.getItem("openui.devtools.config") ?? "{}").position).toBe( + "top-left", + ); + + remount({ enabled: true }); + expect(toggle().parentElement!.style.top).toBe("16px"); + expect(toggle().parentElement!.style.left).toBe("16px"); + }); +}); + +describe("nearestCorner", () => { + const viewport = { width: 1000, height: 800 }; + + it("snaps to the quadrant that contains the button center", () => { + expect(nearestCorner(0, 0, viewport)).toBe("top-left"); + expect(nearestCorner(960, 0, viewport)).toBe("top-right"); + expect(nearestCorner(0, 760, viewport)).toBe("bottom-left"); + expect(nearestCorner(960, 760, viewport)).toBe("bottom-right"); + }); }); diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6f7e490e0..45c125f71 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -13,10 +13,13 @@ import { } from "./inspect"; import { addOrReplaceEvent, + DEFAULT_POSITION, isLibraryEvent, useDevtoolsConfig, useDevtoolsSingleton, + useSnapCorner, type DevtoolsConfig, + type DevtoolsPosition, } from "./lib"; import { DEFAULT_COLOR_MODE, @@ -30,6 +33,8 @@ import { } from "./theme"; import { ErrorBoundary, IconButton, ShiroLogo, ThemeSegmented } from "./ui"; +export type { DevtoolsPosition }; + /** Uniform row height for the settings menu, set by its tallest control. */ const MENU_ROW_HEIGHT = 28; @@ -46,12 +51,13 @@ const DEBUG_MIN_WIDTH = 360; const BLOCK_W = `min(85vw, 3456px)`; const BLOCK_H = `min(85vh, 2234px)`; -export type DevtoolsPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; - export interface OpenUIDevtoolsProps { /** Force the widget on/off. Defaults to on outside production builds. */ enabled?: boolean; - /** Corner for the floating toggle button. Defaults to "bottom-right". */ + /** + * @deprecated Drag the floating button to snap it to a corner. + * This prop is ignored. The position is persisted + */ position?: DevtoolsPosition; /** How many events to keep; oldest are dropped first. */ maxEvents?: number; @@ -80,12 +86,13 @@ export interface OpenUIDevtoolsProps { * stack trace. OpenUI Inspect and OpenUI Debug are independent tools on * independent trays: a stream's Debug button opens Debug beside Inspect, and * either closes without disturbing the other. Display filters and the theme - * live in the header settings menu. Renders nothing in production unless + * live in the header settings menu. Drag the floating button to snap it to a + * corner; the choice is remembered. Renders nothing in production unless * `enabled` is set explicitly. */ export function OpenUIDevtools({ enabled, - position = "bottom-right", + position: _position, maxEvents = 50, errorsOnly = false, autoOpenOnError = true, @@ -107,10 +114,16 @@ export function OpenUIDevtools({ theme: DEFAULT_COLOR_MODE, helpSeen: false, editorPct: DEFAULT_EDITOR_PCT, + position: DEFAULT_POSITION, }, { theme: themeProp }, ); - const { onlyErrors, theme: mode } = config; + const { onlyErrors, theme: mode, position } = config; + const snap = useSnapCorner({ + position, + onSnap: (next) => setConfig({ position: next }), + onActivate: () => setOpen(true), + }); const debug = useDebug({ theme: mode, helpSeen: config.helpSeen, @@ -119,6 +132,13 @@ export function OpenUIDevtools({ }); const styles = uiStyles(theme(mode)); + useEffect(() => { + if (_position == null) return; + console.warn( + "[@openuidev/devtools] The `position` prop is deprecated. Drag the toggle to snap it to a corner.", + ); + }, [_position]); + // Read configRef inside the (stable) subscription without re-subscribing. useEffect(() => { if (!isEnabled) return; @@ -174,16 +194,21 @@ export function OpenUIDevtools({ return ( -
+