Skip to content

Commit 07b9cdc

Browse files
feat: hot corners
1 parent 519bea6 commit 07b9cdc

5 files changed

Lines changed: 343 additions & 37 deletions

File tree

packages/devtools/src/components/tanstack-trigger-mark.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import { createUniqueId } from 'solid-js'
33
// The rainbow favicon paints its own colours so it stays readable on any host
44
// page. These literals are the official dark-favicon stops, not theme tokens.
55
const PALM_INK = '#171717' // semantic-color-exempt: trigger-rainbow-mark
6-
export const RAINBOW_STOP_0 = '#FF5F5F' // semantic-color-exempt: trigger-rainbow-mark
7-
export const RAINBOW_STOP_1 = '#FFA05C' // semantic-color-exempt: trigger-rainbow-mark
8-
export const RAINBOW_STOP_2 = '#FFF27C' // semantic-color-exempt: trigger-rainbow-mark
9-
export const RAINBOW_STOP_3 = '#74DCFF' // semantic-color-exempt: trigger-rainbow-mark
6+
const RAINBOW_STOP_0 = '#FF5F5F' // semantic-color-exempt: trigger-rainbow-mark
7+
const RAINBOW_STOP_1 = '#FFA05C' // semantic-color-exempt: trigger-rainbow-mark
8+
const RAINBOW_STOP_2 = '#FFF27C' // semantic-color-exempt: trigger-rainbow-mark
9+
const RAINBOW_STOP_3 = '#74DCFF' // semantic-color-exempt: trigger-rainbow-mark
10+
// Exposed as a single opaque CSS value so the edge tab button (a plain DOM
11+
// element, not an SVG) can paint the same rainbow as its background.
12+
export const TRIGGER_MARK_GRADIENT = `linear-gradient(to bottom, ${RAINBOW_STOP_0} 0%, ${RAINBOW_STOP_1} 34.4449%, ${RAINBOW_STOP_2} 73.3354%, ${RAINBOW_STOP_3} 100%)` // semantic-color-exempt: trigger-rainbow-mark
1013

1114
/**
1215
* The default trigger mark: the rainbow palm from the TanStack dark favicon,

packages/devtools/src/components/trigger.test.tsx

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
clamp,
99
cornerAt,
1010
cornerCoords,
11+
directionCorner,
1112
offScreenEdge,
13+
quadrantCorner,
1214
stepAxis,
1315
} from './trigger'
1416
import type { TanStackDevtoolsConfig } from '../context/devtools-context'
@@ -133,6 +135,43 @@ describe('hot corners', () => {
133135
})
134136
})
135137

138+
describe('magnetic direction', () => {
139+
it('reads a corner from the direction of a nudge alone', () => {
140+
expect(directionCorner(-40, -40, null)).toBe('top-left')
141+
expect(directionCorner(40, -40, null)).toBe('top-right')
142+
expect(directionCorner(-40, 40, null)).toBe('bottom-left')
143+
expect(directionCorner(40, 40, null)).toBe('bottom-right')
144+
})
145+
146+
it('keeps the fallback when neither axis leaves the deadzone', () => {
147+
expect(directionCorner(4, -4, 'bottom-left')).toBe('bottom-left')
148+
expect(directionCorner(0, 0, null)).toBeNull()
149+
})
150+
151+
it('ignores sideways wander on a long drag along one axis', () => {
152+
expect(directionCorner(12, -200, 'bottom-left')).toBe('top-left')
153+
expect(directionCorner(-12, -200, 'bottom-right')).toBe('top-right')
154+
expect(directionCorner(200, 12, 'top-right')).toBe('top-right')
155+
expect(directionCorner(200, -12, 'bottom-left')).toBe('bottom-right')
156+
})
157+
158+
it('still reads a diagonal once the minor axis is deliberate', () => {
159+
expect(directionCorner(120, -200, 'bottom-left')).toBe('top-right')
160+
expect(directionCorner(-120, -200, 'bottom-right')).toBe('top-left')
161+
})
162+
163+
it('falls back to the quadrant the trigger sits in', () => {
164+
const size = { width: 56, height: 56 }
165+
const viewport = { width: 1024, height: 768 }
166+
expect(quadrantCorner({ x: 8, y: 8 }, size, viewport)).toBe('top-left')
167+
expect(quadrantCorner({ x: 960, y: 8 }, size, viewport)).toBe('top-right')
168+
expect(quadrantCorner({ x: 8, y: 704 }, size, viewport)).toBe('bottom-left')
169+
expect(quadrantCorner({ x: 960, y: 704 }, size, viewport)).toBe(
170+
'bottom-right',
171+
)
172+
})
173+
})
174+
136175
describe('off screen edge', () => {
137176
const size = { width: 56, height: 56 }
138177
const viewport = { width: 1024, height: 768 }
@@ -217,6 +256,80 @@ describe('dragging a floating trigger into a corner', () => {
217256
})
218257
})
219258

259+
describe('the drag tooltip', () => {
260+
beforeEach(() => {
261+
localStorage.clear()
262+
Element.prototype.setPointerCapture = vi.fn()
263+
Element.prototype.releasePointerCapture = vi.fn()
264+
Element.prototype.hasPointerCapture = vi.fn(() => false)
265+
})
266+
267+
const drag = (el: Element, type: string, x: number, y: number) =>
268+
el.dispatchEvent(
269+
new PointerEvent(type, {
270+
bubbles: true,
271+
button: 0,
272+
pointerId: 1,
273+
clientX: x,
274+
clientY: y,
275+
}),
276+
)
277+
278+
it('stays up when the pointer outruns the button mid-drag', () => {
279+
const { getByLabelText, queryByText } = renderTrigger({
280+
triggerMode: 'floating',
281+
})
282+
const button = getByLabelText('Open TanStack Devtools')
283+
284+
drag(button, 'pointerdown', 400, 400)
285+
expect(queryByText(/Drag to move/)).not.toBeNull()
286+
287+
fireEvent.mouseLeave(button)
288+
drag(button, 'pointermove', 700, 300)
289+
expect(queryByText(/Drag to move/)).not.toBeNull()
290+
291+
drag(button, 'pointerup', 700, 300)
292+
expect(queryByText(/Drag to move/)).toBeNull()
293+
})
294+
295+
it('gets out of the way while the drag is still going', () => {
296+
vi.useFakeTimers()
297+
try {
298+
const { getByLabelText, queryByText } = renderTrigger({
299+
triggerMode: 'floating',
300+
})
301+
const button = getByLabelText('Open TanStack Devtools')
302+
303+
drag(button, 'pointerdown', 400, 400)
304+
expect(queryByText(/Drag to move/)).not.toBeNull()
305+
306+
vi.advanceTimersByTime(1500)
307+
expect(queryByText(/Drag to move/)).toBeNull()
308+
309+
drag(button, 'pointermove', 500, 400)
310+
expect(queryByText(/Drag to move/)).toBeNull()
311+
} finally {
312+
vi.useRealTimers()
313+
}
314+
})
315+
316+
it('still hides on hover-out when no drag is in flight', () => {
317+
const { getByLabelText, queryByText } = renderTrigger({
318+
triggerMode: 'floating',
319+
})
320+
const button = getByLabelText('Open TanStack Devtools')
321+
322+
drag(button, 'pointerdown', 400, 400)
323+
drag(button, 'pointerup', 400, 400)
324+
drag(button, 'pointerdown', 400, 400)
325+
expect(queryByText(/Drag to move/)).not.toBeNull()
326+
327+
drag(button, 'pointercancel', 400, 400)
328+
fireEvent.mouseLeave(button)
329+
expect(queryByText(/Drag to move/)).toBeNull()
330+
})
331+
})
332+
220333
describe('escape during a trigger drag', () => {
221334
beforeEach(() => {
222335
localStorage.clear()
@@ -269,8 +382,8 @@ describe('escape during a trigger drag', () => {
269382
const button = getByLabelText('Open TanStack Devtools')
270383

271384
drag(button, 'pointerdown', 0, 0)
272-
drag(button, 'pointermove', 2000, 2000)
273-
drag(button, 'pointerup', 2000, 2000)
385+
drag(button, 'pointermove', 1016, 760)
386+
drag(button, 'pointerup', 1016, 760)
274387
expect(storedSettings().triggerCorner).toBe('bottom-right')
275388

276389
drag(button, 'pointerdown', 1016, 760)
@@ -285,8 +398,8 @@ describe('escape during a trigger drag', () => {
285398
const button = getByLabelText('Open TanStack Devtools')
286399

287400
drag(button, 'pointerdown', 0, 0)
288-
drag(button, 'pointermove', 2000, 2000)
289-
drag(button, 'pointerup', 2000, 2000)
401+
drag(button, 'pointermove', 1016, 760)
402+
drag(button, 'pointerup', 1016, 760)
290403

291404
escape()
292405
expect(storedSettings().triggerCoords).toEqual({ x: 1016, y: 760 })
@@ -466,6 +579,26 @@ describe('dragging the trigger off screen', () => {
466579
).not.toBeInTheDocument()
467580
})
468581

582+
it('docks to the edge even when the release is also corner-hot', () => {
583+
const { container, getByLabelText } = renderTrigger({
584+
triggerMode: 'floating',
585+
})
586+
const button = getByLabelText('Open TanStack Devtools')
587+
588+
drag(button, 'pointerdown', 0, 0)
589+
drag(button, 'pointermove', 5000, 0)
590+
591+
expect(container.querySelector('[data-tsd-hot-corner]')).toBeNull()
592+
expect(
593+
getByLabelText('Show TanStack Devtools trigger'),
594+
).toBeInTheDocument()
595+
596+
drag(button, 'pointerup', 5000, 0)
597+
598+
expect(storedSettings().triggerEdge).toBe('right')
599+
expect(storedSettings().triggerCorner).toBeUndefined()
600+
})
601+
469602
it('keeps the trigger when the release never crosses the edge', () => {
470603
const { getByLabelText, queryByLabelText } = renderTrigger({
471604
triggerMode: 'floating',

0 commit comments

Comments
 (0)