From cf14f200dfd9665f5d0346dc6d4311114acbaf42 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 13 Feb 2026 13:54:46 -0500 Subject: [PATCH 1/3] feat(ui): Add dragging to keyless prompt --- packages/clerk-js/sandbox/template.html | 5 +- .../__tests__/KeylessPrompt.test.tsx | 125 ++++- .../devPrompts/KeylessPrompt/index.tsx | 27 +- .../KeylessPrompt/use-drag-to-corner.ts | 464 ++++++++++++++++++ 4 files changed, 610 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 7e35aa1331b..d8ff8041392 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -1,8 +1,5 @@ - + clerk-js Sandbox diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx index d0ab9733be0..dc7da670125 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/__tests__/KeylessPrompt.test.tsx @@ -1,10 +1,18 @@ import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render } from '@/test/utils'; import { getCurrentState, getResolvedContent, KeylessPrompt } from '../index'; +import { + calculateVelocity, + getCornerStyles, + getNearestCorner, + project, + saveCornerPreference, + STORAGE_KEY, +} from '../use-drag-to-corner'; const { createFixtures } = bindCreateFixtures('KeylessPrompt' as any); @@ -333,3 +341,118 @@ describe('KeylessPrompt component', () => { expect(getByText(/Add SSO connections/i)).toBeInTheDocument(); }); }); + +describe('useDragToCorner utilities', () => { + describe('getNearestCorner', () => { + const corners = { + 'top-left': { x: -500, y: -500 }, + 'top-right': { x: 500, y: -500 }, + 'bottom-left': { x: -500, y: 500 }, + 'bottom-right': { x: 0, y: 0 }, + } as const; + + it('returns the nearest corner to a given translation', () => { + expect(getNearestCorner({ x: -400, y: -400 }, corners)).toBe('top-left'); + expect(getNearestCorner({ x: 400, y: -400 }, corners)).toBe('top-right'); + expect(getNearestCorner({ x: -400, y: 400 }, corners)).toBe('bottom-left'); + expect(getNearestCorner({ x: 10, y: 10 }, corners)).toBe('bottom-right'); + }); + + it('returns the exact corner when translation matches', () => { + expect(getNearestCorner({ x: 0, y: 0 }, corners)).toBe('bottom-right'); + expect(getNearestCorner({ x: -500, y: -500 }, corners)).toBe('top-left'); + }); + + it('handles equidistant case by returning the first found', () => { + // When equidistant from multiple corners, the iteration order determines the result + const result = getNearestCorner({ x: 0, y: -500 }, corners); + expect(['top-left', 'top-right']).toContain(result); + }); + }); + + describe('getCornerStyles', () => { + it('returns correct CSS for top-left', () => { + expect(getCornerStyles('top-left')).toEqual({ top: '1.25rem', left: '1.25rem' }); + }); + + it('returns correct CSS for top-right', () => { + expect(getCornerStyles('top-right')).toEqual({ top: '1.25rem', right: '1.25rem' }); + }); + + it('returns correct CSS for bottom-left', () => { + expect(getCornerStyles('bottom-left')).toEqual({ bottom: '1.25rem', left: '1.25rem' }); + }); + + it('returns correct CSS for bottom-right', () => { + expect(getCornerStyles('bottom-right')).toEqual({ bottom: '1.25rem', right: '1.25rem' }); + }); + }); + + describe('saveCornerPreference', () => { + afterEach(() => { + localStorage.removeItem(STORAGE_KEY); + }); + + it('saves corner to localStorage', () => { + saveCornerPreference('top-left'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('top-left'); + }); + + it('overwrites previous value', () => { + saveCornerPreference('top-left'); + saveCornerPreference('bottom-right'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('bottom-right'); + }); + }); + + describe('project', () => { + it('returns 0 for zero velocity', () => { + expect(project(0)).toBe(0); + }); + + it('returns positive value for positive velocity', () => { + expect(project(1000)).toBeGreaterThan(0); + }); + + it('returns negative value for negative velocity', () => { + expect(project(-1000)).toBeLessThan(0); + }); + + it('scales proportionally with velocity', () => { + const single = project(500); + const double = project(1000); + expect(double).toBeCloseTo(single * 2); + }); + }); + + describe('calculateVelocity', () => { + it('returns zero for empty history', () => { + expect(calculateVelocity([])).toEqual({ x: 0, y: 0 }); + }); + + it('returns zero for single entry', () => { + expect(calculateVelocity([{ position: { x: 10, y: 20 }, timestamp: 100 }])).toEqual({ x: 0, y: 0 }); + }); + + it('returns zero when time delta is zero', () => { + const history = [ + { position: { x: 0, y: 0 }, timestamp: 100 }, + { position: { x: 50, y: 50 }, timestamp: 100 }, + ]; + expect(calculateVelocity(history)).toEqual({ x: 0, y: 0 }); + }); + + it('calculates velocity correctly using first and last entries', () => { + const history = [ + { position: { x: 0, y: 0 }, timestamp: 0 }, + { position: { x: 50, y: 50 }, timestamp: 50 }, + { position: { x: 100, y: 200 }, timestamp: 100 }, + ]; + const velocity = calculateVelocity(history); + // (100 - 0) / 100 * 1000 = 1000 + expect(velocity.x).toBe(1000); + // (200 - 0) / 100 * 1000 = 2000 + expect(velocity.y).toBe(2000); + }); + }); +}); diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx index 5bd8d6517ce..e9e6854c03c 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx @@ -5,6 +5,7 @@ import { type ReactNode, useId, useMemo, useState } from 'react'; import { InternalThemeProvider } from '../../../styledSystem'; import { handleDashboardUrlParsing } from '../shared'; +import { useDragToCorner } from './use-drag-to-corner'; import { useRevalidateEnvironment } from './use-revalidate-environment'; type KeylessPromptProps = { @@ -287,6 +288,7 @@ export function getResolvedContent(state: STATES, context: ResolvedContentContex function KeylessPromptInternal(props: KeylessPromptProps) { const id = useId(); const environment = useRevalidateEnvironment(); + const { isDragging, cornerStyle, containerRef, onPointerDown, preventClick, isInitialized } = useDragToCorner(); const claimed = Boolean(environment.authConfig.claimedAt); const success = typeof props.onDismiss === 'function' && claimed; @@ -348,12 +350,16 @@ function KeylessPromptInternal(props: KeylessPromptProps) { return (
setIsOpen(p => !p)} + onClick={() => { + if (preventClick) { + return; + } + setIsOpen(p => !p); + }} css={css` ${CSS_RESET}; display: flex; diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts new file mode 100644 index 00000000000..e4177dd6752 --- /dev/null +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts @@ -0,0 +1,464 @@ +import type { PointerEventHandler } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export const STORAGE_KEY = 'clerk-keyless-prompt-corner'; +const CORNER_OFFSET = '1.25rem'; +const CORNER_OFFSET_PX = 20; // 1.25rem ≈ 20px +const DRAG_THRESHOLD = 5; +const VELOCITY_SAMPLE_INTERVAL_MS = 10; +const VELOCITY_HISTORY_SIZE = 5; +const INERTIA_DECELERATION_RATE = 0.999; +const SPRING_DURATION = '350ms'; +const SPRING_EASING = 'cubic-bezier(0.34, 1.2, 0.64, 1)'; +const SPRING_TRANSITION = `transform ${SPRING_DURATION} ${SPRING_EASING}`; +const ZERO_TRANSFORM = 'translate3d(0px, 0px, 0)'; +const ZERO_POINT: Point = { x: 0, y: 0 }; + +interface Point { + x: number; + y: number; +} + +interface Velocity { + position: Point; + timestamp: number; +} + +interface CornerTranslation { + corner: Corner; + translation: Point; +} + +interface UseDragToCornerResult { + corner: Corner; + isDragging: boolean; + cornerStyle: React.CSSProperties; + containerRef: React.RefObject; + onPointerDown: PointerEventHandler; + preventClick: boolean; + isInitialized: boolean; +} + +export function getNearestCorner(projectedTranslation: Point, corners: Record): Corner { + let nearestCorner: Corner = 'bottom-right'; + let minDistance = Infinity; + + for (const [corner, translation] of Object.entries(corners)) { + const dx = projectedTranslation.x - translation.x; + const dy = projectedTranslation.y - translation.y; + const distance = Math.sqrt(dx * dx + dy * dy); + if (distance < minDistance) { + minDistance = distance; + nearestCorner = corner as Corner; + } + } + + return nearestCorner; +} + +export function getCornerStyles(corner: Corner): React.CSSProperties { + switch (corner) { + case 'top-left': + return { top: CORNER_OFFSET, left: CORNER_OFFSET }; + case 'top-right': + return { top: CORNER_OFFSET, right: CORNER_OFFSET }; + case 'bottom-left': + return { bottom: CORNER_OFFSET, left: CORNER_OFFSET }; + case 'bottom-right': + return { bottom: CORNER_OFFSET, right: CORNER_OFFSET }; + } +} + +const VALID_CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; + +export function saveCornerPreference(corner: Corner): void { + if (typeof window === 'undefined') { + return; + } + try { + localStorage.setItem(STORAGE_KEY, corner); + } catch { + // Ignore localStorage errors + } +} + +export function project(initialVelocity: number): number { + return ((initialVelocity / 1000) * INERTIA_DECELERATION_RATE) / (1 - INERTIA_DECELERATION_RATE); +} + +export function calculateVelocity(history: Velocity[]): Point { + if (history.length < 2) { + return ZERO_POINT; + } + + const oldest = history[0]; + const latest = history[history.length - 1]; + const timeDelta = latest.timestamp - oldest.timestamp; + + if (timeDelta === 0) { + return ZERO_POINT; + } + + return { + x: ((latest.position.x - oldest.position.x) / timeDelta) * 1000, + y: ((latest.position.y - oldest.position.y) / timeDelta) * 1000, + }; +} + +export function useDragToCorner(): UseDragToCornerResult { + // Initialize with deterministic server-safe value to avoid SSR/hydration mismatch + const [corner, setCorner] = useState('bottom-right'); + const [isDragging, setIsDragging] = useState(false); + const [preventClick, setPreventClick] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const pendingCornerUpdate = useRef(null); + + // Defer localStorage read to client-side only after mount + useEffect(() => { + if (typeof window === 'undefined') { + setIsInitialized(true); + return; + } + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored && VALID_CORNERS.includes(stored as Corner)) { + setCorner(stored as Corner); + } + } catch { + // Ignore localStorage errors + } finally { + setIsInitialized(true); + } + }, []); + + const containerRef = useRef(null); + const machine = useRef<{ state: 'idle' | 'press' | 'animating' } | { state: 'drag'; pointerId: number }>({ + state: 'idle', + }); + + const cleanup = useRef<(() => void) | null>(null); + const origin = useRef({ x: 0, y: 0 }); + const translation = useRef({ x: 0, y: 0 }); + const lastTimestamp = useRef(0); + const velocities = useRef([]); + const dragStartDimensions = useRef<{ width: number; height: number } | null>(null); + + const setTranslation = useCallback((position: Point) => { + if (!containerRef.current) { + return; + } + translation.current = position; + containerRef.current.style.transform = `translate3d(${position.x}px, ${position.y}px, 0)`; + }, []); + + const getCorners = useCallback((): Record => { + const container = containerRef.current; + if (!container) { + return { + 'top-left': ZERO_POINT, + 'top-right': ZERO_POINT, + 'bottom-left': ZERO_POINT, + 'bottom-right': ZERO_POINT, + }; + } + + // Use stored dimensions if available (during drag), otherwise read current dimensions + const triggerWidth = dragStartDimensions.current?.width ?? container.offsetWidth ?? 0; + const triggerHeight = dragStartDimensions.current?.height ?? container.offsetHeight ?? 0; + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + + function getAbsolutePosition(c: Corner): Point { + const isRight = c.includes('right'); + const isBottom = c.includes('bottom'); + return { + x: isRight ? window.innerWidth - scrollbarWidth - CORNER_OFFSET_PX - triggerWidth : CORNER_OFFSET_PX, + y: isBottom ? window.innerHeight - CORNER_OFFSET_PX - triggerHeight : CORNER_OFFSET_PX, + }; + } + + const base = getAbsolutePosition(corner); + + function toRelative(c: Corner): Point { + const pos = getAbsolutePosition(c); + return { x: pos.x - base.x, y: pos.y - base.y }; + } + + return { + 'top-left': toRelative('top-left'), + 'top-right': toRelative('top-right'), + 'bottom-left': toRelative('bottom-left'), + 'bottom-right': toRelative('bottom-right'), + }; + }, [corner]); + + const animate = useCallback( + (cornerTranslation: CornerTranslation) => { + const el = containerRef.current; + if (!el) { + return; + } + + // Skip animation if already at target (transitionend won't fire) + const dx = cornerTranslation.translation.x - translation.current.x; + const dy = cornerTranslation.translation.y - translation.current.y; + if (Math.sqrt(dx * dx + dy * dy) < 0.5) { + saveCornerPreference(cornerTranslation.corner); + translation.current = ZERO_POINT; + el.style.transition = ''; + el.style.transform = ZERO_TRANSFORM; + machine.current = { state: 'idle' }; + setPreventClick(false); + return; + } + + const handleAnimationEnd = (e: TransitionEvent) => { + // Ignore non-transform transitions (width, border-radius from Emotion CSS) + if (e.propertyName !== 'transform') return; + el.removeEventListener('transitionend', handleAnimationEnd); + + saveCornerPreference(cornerTranslation.corner); + + if (cornerTranslation.corner === corner) { + // Same corner — clean up directly, no CSS repositioning needed + translation.current = ZERO_POINT; + el.style.transition = ''; + el.style.transform = ZERO_TRANSFORM; + machine.current = { state: 'idle' }; + setPreventClick(false); + } else { + // Different corner — React state + layoutEffect for flash-free repositioning + machine.current = { state: 'animating' }; + pendingCornerUpdate.current = cornerTranslation.corner; + setCorner(cornerTranslation.corner); + } + }; + + el.style.transition = SPRING_TRANSITION; + el.addEventListener('transitionend', handleAnimationEnd); + setTranslation(cornerTranslation.translation); + }, + [setTranslation, corner], + ); + + const cancel = useCallback(() => { + if (machine.current.state === 'drag') { + containerRef.current?.releasePointerCapture(machine.current.pointerId); + machine.current = { state: 'animating' }; + } else { + machine.current = { state: 'idle' }; + } + + if (cleanup.current) { + cleanup.current(); + cleanup.current = null; + } + + velocities.current = []; + setIsDragging(false); + dragStartDimensions.current = null; // Clear stored dimensions + containerRef.current?.classList.remove('dev-tools-grabbing'); + document.body.style.removeProperty('user-select'); + document.body.style.removeProperty('-webkit-user-select'); + + // Don't reset translation on simple clicks - it should already be correct + // Only reset if we were actually dragging and need to clean up + // Translation is reset to zero when snapping to corners, so simple clicks don't need reset + }, []); + + useLayoutEffect(() => { + if (pendingCornerUpdate.current === corner) { + const el = containerRef.current; + if (el && machine.current.state === 'animating') { + translation.current = ZERO_POINT; + el.style.transition = ''; + el.style.transform = ZERO_TRANSFORM; + machine.current = { state: 'idle' }; + setPreventClick(false); + pendingCornerUpdate.current = null; + } + } + }, [corner]); + + useLayoutEffect(() => { + return () => { + cancel(); + }; + }, [cancel]); + + const handlePointerDown: PointerEventHandler = useCallback( + e => { + const target = e.target as HTMLElement; + if (target.tagName === 'A' || target.closest('a')) { + return; + } + + if (e.button !== 0) { + return; + } + + const container = containerRef.current; + if (!container) { + return; + } + + // Store dimensions at drag start to prevent issues with animating width + dragStartDimensions.current = { + width: container.offsetWidth, + height: container.offsetHeight, + }; + + origin.current = { x: e.clientX, y: e.clientY }; + + // Read the current transform from the element's style to sync translation.current + // This ensures we start from the actual current position, not a stale value + const currentTransform = container.style.transform; + if (currentTransform && currentTransform !== 'none' && currentTransform !== ZERO_TRANSFORM) { + // Parse translate3d(x, y, 0) to extract x and y values + const match = currentTransform.match(/translate3d\(([^,]+)px,\s*([^,]+)px/); + if (match) { + translation.current = { + x: parseFloat(match[1]) || 0, + y: parseFloat(match[2]) || 0, + }; + } + } else { + translation.current = ZERO_POINT; + } + + machine.current = { state: 'press' }; + velocities.current = []; + lastTimestamp.current = Date.now(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (machine.current.state === 'press') { + const dx = moveEvent.clientX - origin.current.x; + const dy = moveEvent.clientY - origin.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < DRAG_THRESHOLD) { + return; + } + + machine.current = { state: 'drag', pointerId: moveEvent.pointerId }; + try { + container.setPointerCapture(moveEvent.pointerId); + } catch { + // Pointer capture may fail - drag still works without it + } + + // Disable all transitions during drag + container.style.transition = 'none'; + + container.classList.add('dev-tools-grabbing'); + document.body.style.userSelect = 'none'; + document.body.style.webkitUserSelect = 'none'; + setIsDragging(true); + + // Apply the initial movement that got us past the threshold + setTranslation({ + x: translation.current.x + dx, + y: translation.current.y + dy, + }); + + // Update origin for next calculation + origin.current = { x: moveEvent.clientX, y: moveEvent.clientY }; + return; + } + + if (machine.current.state !== 'drag') { + return; + } + + const currentPosition = { x: moveEvent.clientX, y: moveEvent.clientY }; + const dx = currentPosition.x - origin.current.x; + const dy = currentPosition.y - origin.current.y; + + // Update origin for next calculation + origin.current = currentPosition; + + // Apply translation relative to current position + setTranslation({ + x: translation.current.x + dx, + y: translation.current.y + dy, + }); + + const now = Date.now(); + if (now - lastTimestamp.current >= VELOCITY_SAMPLE_INTERVAL_MS) { + velocities.current = [ + ...velocities.current.slice(-VELOCITY_HISTORY_SIZE + 1), + { position: currentPosition, timestamp: now }, + ]; + lastTimestamp.current = now; + } + }; + + const handlePointerUp = () => { + const wasDragging = machine.current.state === 'drag'; + + if (wasDragging) { + const velocity = calculateVelocity(velocities.current); + const allCorners = getCorners(); + cancel(); + + const container = containerRef.current; + if (!container) { + return; + } + + const projectedTranslation = { + x: translation.current.x + project(velocity.x), + y: translation.current.y + project(velocity.y), + }; + + const newCorner = getNearestCorner(projectedTranslation, allCorners); + const targetTranslation = allCorners[newCorner]; + + setPreventClick(true); + animate({ corner: newCorner, translation: targetTranslation }); + } else { + cancel(); + } + }; + + const handleClick = (clickEvent: MouseEvent) => { + const target = clickEvent.target as HTMLElement; + const isButton = target.tagName === 'BUTTON' || target.closest('button'); + const isLink = target.tagName === 'A' || target.closest('a'); + + if (machine.current.state === 'animating' && !isButton && !isLink) { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + } + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp, { once: true }); + window.addEventListener('pointercancel', cancel, { once: true }); + container.addEventListener('click', handleClick); + + if (cleanup.current) { + cleanup.current(); + } + + cleanup.current = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', cancel); + container.removeEventListener('click', handleClick); + }; + }, + [cancel, setTranslation, animate, getCorners], + ); + + return { + corner, + isDragging, + cornerStyle: getCornerStyles(corner), + containerRef, + onPointerDown: handlePointerDown, + preventClick, + isInitialized, + }; +} From 34284403c32301f27fd937df6e3537ddcfd27d91 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 13 Feb 2026 13:55:41 -0500 Subject: [PATCH 2/3] add changeset --- .changeset/mighty-pigs-help.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mighty-pigs-help.md diff --git a/.changeset/mighty-pigs-help.md b/.changeset/mighty-pigs-help.md new file mode 100644 index 00000000000..b522d86a3f3 --- /dev/null +++ b/.changeset/mighty-pigs-help.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Add drag to corner functionality to the KeylessPrompt From a54f6b37c5089e6bd10ab971744baeaad21873e9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 13 Feb 2026 14:27:46 -0500 Subject: [PATCH 3/3] fix warnings --- .../devPrompts/KeylessPrompt/use-drag-to-corner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts index e4177dd6752..469c6cf0b40 100644 --- a/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts +++ b/packages/ui/src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts @@ -61,12 +61,16 @@ export function getNearestCorner(projectedTranslation: Point, corners: Record { // Ignore non-transform transitions (width, border-radius from Emotion CSS) - if (e.propertyName !== 'transform') return; + if (e.propertyName !== 'transform') { + return; + } el.removeEventListener('transitionend', handleAnimationEnd); saveCornerPreference(cornerTranslation.corner);