Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/floating-draggable-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/devtools': minor
---

Add a floating trigger mode. Set `triggerMode: 'floating'` (or choose it under
Settings → Trigger Mode) to drag the devtools trigger anywhere on screen with
the left mouse button. Releasing a drag with velocity throws it — it glides with
momentum and springs back off the screen edges. The trigger is always kept
within a padded, on-screen area (it can never end up off-screen) and its
position is persisted to local storage.
8 changes: 7 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,18 @@ The `config` object is mainly focused around user interaction with the devtools
{ hideUntilHover: boolean }
```

- `position` - The position of the TanStack devtools trigger
- `position` - The position of the TanStack devtools trigger (used when `triggerMode` is `'fixed'`)

```ts
{ position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'middle-left' | 'middle-right' }
```

- `triggerMode` - How the trigger is placed. `'fixed'` anchors it to `position`; `'floating'` lets you drag the trigger anywhere on screen (and throw it — it glides with momentum and springs back off the edges). The floating spot is persisted in local storage.

```ts
{ triggerMode: 'fixed' | 'floating' }
```

- `panelLocation` - The location of the devtools panel

```ts
Expand Down
50 changes: 49 additions & 1 deletion packages/devtools/src/components/trigger.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render } from '@solidjs/testing-library'
import { createSignal } from 'solid-js'
import { beforeEach, describe, expect, it } from 'vitest'
import { DevtoolsProvider } from '../context/devtools-context'
import { Trigger } from './trigger'
import { Trigger, clamp, stepAxis } from './trigger'
import type { TanStackDevtoolsConfig } from '../context/devtools-context'

const renderTrigger = (config?: Partial<TanStackDevtoolsConfig>) => {
Expand Down Expand Up @@ -37,4 +37,52 @@ describe('Trigger', () => {

expect(queryByLabelText('Open TanStack Devtools')).not.toBeInTheDocument()
})

it('renders the floating trigger without a fixed position class', () => {
const { queryByLabelText } = renderTrigger({ triggerMode: 'floating' })

const button = queryByLabelText('Open TanStack Devtools')
expect(button).toBeInTheDocument()
})
})

describe('throw physics', () => {
it('clamps values into range', () => {
expect(clamp(5, 0, 10)).toBe(5)
expect(clamp(-5, 0, 10)).toBe(0)
expect(clamp(50, 0, 10)).toBe(10)
// Degenerate range (window smaller than trigger + padding): stays at min.
expect(clamp(5, 10, 0)).toBe(10)
})

it('advances position by velocity while inside the walls', () => {
const { pos, vel } = stepAxis(100, 10, 0, 500)
expect(pos).toBe(110)
expect(vel).toBeCloseTo(9.5) // 10 * FRICTION(0.95)
})

it('bounces and damps velocity at a wall', () => {
const hitMax = stepAxis(495, 20, 0, 500)
expect(hitMax.pos).toBe(500)
expect(hitMax.vel).toBeLessThan(0) // reversed
// 20 * 0.95 = 19, reversed & damped by RESTITUTION(0.5) => -9.5
expect(hitMax.vel).toBeCloseTo(-9.5)

const hitMin = stepAxis(5, -20, 0, 500)
expect(hitMin.pos).toBe(0)
expect(hitMin.vel).toBeGreaterThan(0)
})

it('a throw decays to a stop (loop terminates within bounds)', () => {
let pos = 250
let vel = 40
let frames = 0
while (Math.abs(vel) > 0.1 && frames < 10000) {
;({ pos, vel } = stepAxis(pos, vel, 0, 500))
frames++
}
expect(frames).toBeLessThan(10000)
expect(pos).toBeGreaterThanOrEqual(0)
expect(pos).toBeLessThanOrEqual(500)
})
})
262 changes: 258 additions & 4 deletions packages/devtools/src/components/trigger.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,263 @@
import { Show, createEffect, createMemo, createSignal } from 'solid-js'
import {
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
untrack,
} from 'solid-js'
import clsx from 'clsx'
import { createDevtoolsSettings } from '../context/use-devtools-context'
import { createStyles } from '../styles/use-styles'
import TanStackLogo from './tanstack-logo.png'
import type { TriggerCoords } from '../context/devtools-store'
import type { Accessor } from 'solid-js'

// --- Throw physics (pure, unit-tested in trigger.test.tsx) ---
const FRICTION = 0.95 // velocity retained each frame
const RESTITUTION = 0.5 // velocity retained after a wall bounce
const MIN_SPEED = 0.1 // px/frame below which the throw stops
const DRAG_THRESHOLD = 4 // px of movement before a press counts as a drag
const PADDING_RATIO = 0.5 // matches size[2] = --tsrd-font-size * 0.5

export const clamp = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value))

/**
* Advance one axis by its velocity for a single frame, bouncing off the
* [min, max] walls with damping. Returns the new position and velocity.
*/
export const stepAxis = (
pos: number,
vel: number,
min: number,
max: number,
): { pos: number; vel: number } => {
let p = pos + vel
let v = vel * FRICTION
if (p <= min) {
p = min
v = -v * RESTITUTION
} else if (p >= max) {
p = max
v = -v * RESTITUTION
}
return { pos: p, vel: v }
}

export const Trigger = (props: {
isOpen: Accessor<boolean>
setIsOpen: (isOpen: boolean) => void
}) => {
const { settings } = createDevtoolsSettings()
const { settings, setSettings } = createDevtoolsSettings()
const [containerRef, setContainerRef] = createSignal<HTMLElement>()
const [buttonRef, setButtonRef] = createSignal<HTMLButtonElement>()
const [coords, setCoords] = createSignal<TriggerCoords | null>(
settings().triggerCoords ?? null,
)
const styles = createStyles()

const isFloating = createMemo(() => settings().triggerMode === 'floating')

const buttonStyle = createMemo(() => {
return clsx(
styles().mainCloseBtn,
styles().mainCloseBtnPosition(settings().position),
// Keep the fixed-position class until floating coords are seeded, so the
// seed reads the trigger's real on-screen spot (not the unpositioned
// static-flow position) and the hand-off to inline left/top is seamless.
(!isFloating() || !coords()) &&
styles().mainCloseBtnPosition(settings().position),
styles().mainCloseBtnAnimation(props.isOpen(), settings().hideUntilHover),
isFloating() && styles().mainCloseBtnFloating,
)
})

// Padding away from the edges, matching the fixed trigger's offset (size[2]).
const edgePadding = (el: HTMLElement) => {
const fontSize = parseFloat(
getComputedStyle(el).getPropertyValue('--tsrd-font-size'),
)
return (Number.isFinite(fontSize) ? fontSize : 16) * PADDING_RATIO
}

const bounds = (el: HTMLElement) => {
const pad = edgePadding(el)
const rect = el.getBoundingClientRect()
return {
minX: pad,
minY: pad,
maxX: window.innerWidth - rect.width - pad,
maxY: window.innerHeight - rect.height - pad,
}
}

// --- drag state (non-reactive; only `coords` drives rendering) ---
let dragging = false
let moved = false
let startX = 0
let startY = 0
let startPosX = 0
let startPosY = 0
let lastX = 0
let lastY = 0
let lastT = 0
let vx = 0
let vy = 0
let raf: number | undefined

const cancelThrow = () => {
if (raf !== undefined) {
cancelAnimationFrame(raf)
raf = undefined
}
}

const persist = () => setSettings({ triggerCoords: coords() ?? undefined })

const startThrow = () => {
cancelThrow()
const tick = () => {
const el = buttonRef()
const current = coords()
if (!el || !current) {
raf = undefined
return
}
const b = bounds(el)
const nx = stepAxis(current.x, vx, b.minX, b.maxX)
const ny = stepAxis(current.y, vy, b.minY, b.maxY)
vx = nx.vel
vy = ny.vel
setCoords({ x: nx.pos, y: ny.pos })
if (Math.hypot(vx, vy) > MIN_SPEED) {
raf = requestAnimationFrame(tick)
} else {
raf = undefined
persist()
}
}
raf = requestAnimationFrame(tick)
}

const onPointerDown = (e: PointerEvent) => {
if (!isFloating() || e.button !== 0) return
const el = buttonRef()
const current = coords()
if (!el || !current) return
cancelThrow()
dragging = true
moved = false
el.setPointerCapture(e.pointerId)
startX = e.clientX
startY = e.clientY
startPosX = current.x
startPosY = current.y
lastX = e.clientX
lastY = e.clientY
lastT = e.timeStamp
vx = 0
vy = 0
e.preventDefault()
}

const onPointerMove = (e: PointerEvent) => {
if (!dragging) return
const el = buttonRef()
if (!el) return
const dx = e.clientX - startX
const dy = e.clientY - startY
if (Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true
const b = bounds(el)
setCoords({
x: clamp(startPosX + dx, b.minX, b.maxX),
y: clamp(startPosY + dy, b.minY, b.maxY),
})
// Velocity in px per ~16ms frame, so it plugs straight into stepAxis.
const dt = e.timeStamp - lastT
if (dt > 0) {
vx = ((e.clientX - lastX) / dt) * 16
vy = ((e.clientY - lastY) / dt) * 16
}
lastX = e.clientX
lastY = e.clientY
lastT = e.timeStamp
}

const endDrag = (e: PointerEvent, canThrow: boolean) => {
if (!dragging) return
dragging = false
const el = buttonRef()
if (el?.hasPointerCapture(e.pointerId))
el.releasePointerCapture(e.pointerId)
// If the pointer sat still before release, the last flick velocity is
// stale — don't launch a throw the user didn't actually make.
if (e.timeStamp - lastT > 50) {
vx = 0
vy = 0
}
if (canThrow && moved && Math.hypot(vx, vy) > MIN_SPEED) {
startThrow()
} else {
persist()
}
}

const onPointerUp = (e: PointerEvent) => endDrag(e, true)
// A cancelled pointer (OS gesture, context menu) just drops in place.
const onPointerCancel = (e: PointerEvent) => endDrag(e, false)
Comment on lines +186 to +207

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

does a click event fire after pointercancel

💡 Result:

A click event does not fire after a pointercancel event [1][2][3]. The pointercancel event is specifically designed to signal that the pointer event stream has been suppressed or interrupted (for example, by browser-initiated actions like scrolling, panning, or system-level gestures) [1][2][4]. Because the pointer interaction has been canceled, the browser treats the interaction as incomplete and does not proceed to fire the compatibility mouse events (such as mousedown, mouseup, or click) that would normally conclude a completed pointer sequence [5][2][4]. According to the W3C Pointer Events specification, the pointercancel event is followed by pointerout and pointerleave events to signify the end of the interaction for that pointer [1][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/devtools/src/components/trigger.tsx"

echo "== line count =="
wc -l "$file"

echo
echo "== relevant section =="
sed -n '130,240p' "$file" | cat -n

Repository: TanStack/devtools

Length of output: 4166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="packages/devtools/src/components/trigger.tsx"

rg -n "moved|onClick|pointercancel|pointerup|endDrag|persist\\(" "$file"

Repository: TanStack/devtools

Length of output: 693


Reset moved on pointercancel
A canceled drag that crossed the threshold leaves moved stuck true, and no click follows to clear it. Reset it in the cancel path so the next genuine click still toggles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/devtools/src/components/trigger.tsx` around lines 186 - 207, Reset
the moved state in the pointer-cancel path so a canceled drag cannot affect the
next click. Update onPointerCancel or the cancel handling within endDrag while
preserving the existing drop-in-place behavior and leaving normal pointer-up
throwing unchanged.


const onClick = () => {
// A drag release also fires a click — swallow it so dragging never toggles.
if (moved) {
moved = false
return
}
props.setIsOpen(!props.isOpen())
}

// On going floating: seed coords from the button's current (fixed) position
// if there's no stored spot, otherwise clamp the restored spot into view
// (a saved position from a larger window must not load off-screen).
// Reads/writes coords untracked so this only runs on mode/ref changes.
createEffect(() => {
if (!isFloating()) return
const el = buttonRef()
if (!el) return
untrack(() => {
const current = coords()
if (!current) {
const rect = el.getBoundingClientRect()
setCoords({ x: rect.left, y: rect.top })
return
}
const b = bounds(el)
setCoords({
x: clamp(current.x, b.minX, b.maxX),
y: clamp(current.y, b.minY, b.maxY),
})
})
})

// Keep the trigger on screen when the window is resized.
createEffect(() => {
if (!isFloating()) return
const onResize = () => {
const el = buttonRef()
const current = coords()
if (!el || !current) return
const b = bounds(el)
setCoords({
x: clamp(current.x, b.minX, b.maxX),
y: clamp(current.y, b.minY, b.maxY),
})
persist()
}
window.addEventListener('resize', onResize)
onCleanup(() => window.removeEventListener('resize', onResize))
})

onCleanup(cancelThrow)

createEffect(() => {
const triggerComponent = settings().customTrigger
const el = containerRef()
Expand All @@ -33,10 +271,26 @@ export const Trigger = (props: {
return (
<Show when={!settings().triggerHidden}>
<button
ref={setButtonRef}
type="button"
aria-label="Open TanStack Devtools"
class={buttonStyle()}
onClick={() => props.setIsOpen(!props.isOpen())}
style={
isFloating() && coords()
? {
left: `${coords()!.x}px`,
top: `${coords()!.y}px`,
right: 'auto',
bottom: 'auto',
transform: 'none',
}
: undefined
}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
<Show
when={settings().customTrigger}
Expand Down
Loading
Loading