Skip to content

Commit 0d98d40

Browse files
committed
feat(ui): Add support for dragging keyless prompt
1 parent 87f1fc5 commit 0d98d40

File tree

3 files changed

+360
-24
lines changed

3 files changed

+360
-24
lines changed

packages/ui/src/components/devPrompts/KeylessPrompt/index.tsx

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
PromptSuccessIcon,
1717
} from '../shared';
1818
import { KeySlashIcon } from './KeySlashIcon';
19+
import { useDragToCorner } from './use-drag-to-corner';
1920
import { useRevalidateEnvironment } from './use-revalidate-environment';
2021

2122
type KeylessPromptProps = {
@@ -42,6 +43,7 @@ function withLastActiveFallback(cb: () => string): string {
4243
const KeylessPromptInternal = (_props: KeylessPromptProps) => {
4344
const { isSignedIn } = useUser();
4445
const [isExpanded, setIsExpanded] = useState(false);
46+
const { corner, isDragging, style: positionStyle, containerRef, onPointerDown, preventClick } = useDragToCorner();
4547

4648
useEffect(() => {
4749
if (isSignedIn) {
@@ -114,16 +116,27 @@ const KeylessPromptInternal = (_props: KeylessPromptProps) => {
114116
return (
115117
<Portal>
116118
<PromptContainer
119+
ref={containerRef}
117120
data-expanded={isForcedExpanded}
121+
data-dragging={isDragging}
122+
onPointerDown={onPointerDown}
123+
style={positionStyle}
118124
sx={t => ({
119125
position: 'fixed',
120-
bottom: '1.25rem',
121-
right: '1.25rem',
122126
height: `${t.sizes.$10}`,
123127
minWidth: '13.4rem',
124128
paddingLeft: `${t.space.$3}`,
125129
borderRadius: '1.25rem',
126-
transition: 'all 195ms cubic-bezier(0.2, 0.61, 0.1, 1)',
130+
touchAction: 'none', // Prevent scroll interference on mobile
131+
cursor: isDragging ? 'grabbing' : 'grab',
132+
133+
'&:hover [data-drag-handle]': {
134+
opacity: 0.4,
135+
},
136+
137+
'&[data-dragging="true"] [data-drag-handle]': {
138+
opacity: 0.6,
139+
},
127140

128141
'&[data-expanded="false"]:hover': {
129142
background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.20) 0%, rgba(255, 255, 255, 0) 100%), #1f1f1f',
@@ -140,7 +153,6 @@ const KeylessPromptInternal = (_props: KeylessPromptProps) => {
140153
gap: `${t.space.$1x5}`,
141154
padding: `${t.space.$2x5} ${t.space.$3} ${t.space.$3} ${t.space.$3}`,
142155
borderRadius: `${t.radii.$xl}`,
143-
transition: 'all 230ms cubic-bezier(0.28, 1, 0.32, 1)',
144156
},
145157
})}
146158
>
@@ -149,15 +161,53 @@ const KeylessPromptInternal = (_props: KeylessPromptProps) => {
149161
aria-expanded={isForcedExpanded}
150162
aria-controls={contentIdentifier}
151163
id={buttonIdentifier}
152-
onClick={() => !claimed && setIsExpanded(prev => !prev)}
164+
onClick={e => {
165+
if (preventClick) {
166+
e.preventDefault();
167+
e.stopPropagation();
168+
return;
169+
}
170+
if (!claimed) {
171+
setIsExpanded(prev => !prev);
172+
}
173+
}}
153174
css={css`
154175
${basePromptElementStyles};
155176
width: 100%;
156177
display: flex;
157178
justify-content: space-between;
158179
align-items: center;
180+
position: relative;
159181
`}
160182
>
183+
{/* Drag handle indicator */}
184+
<div
185+
data-drag-handle
186+
css={css`
187+
position: absolute;
188+
left: 0.5rem;
189+
top: 50%;
190+
transform: translateY(-50%);
191+
display: flex;
192+
gap: 0.125rem;
193+
opacity: 0;
194+
transition: opacity 150ms ease-out;
195+
pointer-events: none;
196+
`}
197+
aria-hidden='true'
198+
>
199+
{[...Array(3)].map((_, i) => (
200+
<div
201+
key={i}
202+
css={css`
203+
width: 0.1875rem;
204+
height: 0.1875rem;
205+
background-color: #8c8c8c;
206+
border-radius: 50%;
207+
`}
208+
/>
209+
))}
210+
</div>
161211
<Flex
162212
sx={t => ({
163213
alignItems: 'center',
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import type { PointerEventHandler } from 'react';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
3+
4+
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
5+
6+
const STORAGE_KEY = 'clerk-keyless-prompt-corner';
7+
const LERP_FACTOR = 0.15; // Smooth trailing effect
8+
const INERTIA_MULTIPLIER = 8; // Velocity projection multiplier
9+
const CORNER_OFFSET = '1.25rem';
10+
const DRAG_THRESHOLD = 5; // Minimum pixels to move before starting drag
11+
12+
interface Position {
13+
x: number;
14+
y: number;
15+
}
16+
17+
interface UseDragToCornerResult {
18+
corner: Corner;
19+
isDragging: boolean;
20+
style: React.CSSProperties;
21+
containerRef: React.RefObject<HTMLDivElement>;
22+
onPointerDown: PointerEventHandler;
23+
preventClick: boolean; // Flag to prevent click events after drag
24+
}
25+
26+
// Lerp utility for smooth interpolation
27+
const lerp = (start: number, end: number, factor: number): number => {
28+
return start + (end - start) * factor;
29+
};
30+
31+
// Determine corner based on position relative to viewport center
32+
const getCornerFromPosition = (x: number, y: number): Corner => {
33+
const centerX = window.innerWidth / 2;
34+
const centerY = window.innerHeight / 2;
35+
36+
const isLeft = x < centerX;
37+
const isTop = y < centerY;
38+
39+
if (isTop && isLeft) return 'top-left';
40+
if (isTop && !isLeft) return 'top-right';
41+
if (!isTop && isLeft) return 'bottom-left';
42+
return 'bottom-right';
43+
};
44+
45+
// Get CSS styles for a corner position
46+
const getCornerStyles = (corner: Corner): React.CSSProperties => {
47+
switch (corner) {
48+
case 'top-left':
49+
return { top: CORNER_OFFSET, left: CORNER_OFFSET };
50+
case 'top-right':
51+
return { top: CORNER_OFFSET, right: CORNER_OFFSET };
52+
case 'bottom-left':
53+
return { bottom: CORNER_OFFSET, left: CORNER_OFFSET };
54+
case 'bottom-right':
55+
return { bottom: CORNER_OFFSET, right: CORNER_OFFSET };
56+
}
57+
};
58+
59+
// Get corner position in pixels (for smooth transition)
60+
const getCornerPositionInPixels = (corner: Corner, elementWidth: number, elementHeight: number): Position => {
61+
const offset = 20; // 1.25rem ≈ 20px
62+
switch (corner) {
63+
case 'top-left':
64+
return { x: offset, y: offset };
65+
case 'top-right':
66+
return { x: window.innerWidth - elementWidth - offset, y: offset };
67+
case 'bottom-left':
68+
return { x: offset, y: window.innerHeight - elementHeight - offset };
69+
case 'bottom-right':
70+
return { x: window.innerWidth - elementWidth - offset, y: window.innerHeight - elementHeight - offset };
71+
}
72+
};
73+
74+
// Load corner preference from localStorage
75+
const loadCornerPreference = (): Corner => {
76+
if (typeof window === 'undefined') return 'bottom-right';
77+
try {
78+
const stored = localStorage.getItem(STORAGE_KEY);
79+
if (stored && ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(stored)) {
80+
return stored as Corner;
81+
}
82+
} catch {
83+
// Ignore localStorage errors
84+
}
85+
return 'bottom-right';
86+
};
87+
88+
// Save corner preference to localStorage
89+
const saveCornerPreference = (corner: Corner): void => {
90+
if (typeof window === 'undefined') return;
91+
try {
92+
localStorage.setItem(STORAGE_KEY, corner);
93+
} catch {
94+
// Ignore localStorage errors
95+
}
96+
};
97+
98+
export const useDragToCorner = (): UseDragToCornerResult => {
99+
const [corner, setCorner] = useState<Corner>(loadCornerPreference);
100+
const [isDragging, setIsDragging] = useState(false);
101+
const [dragStyle, setDragStyle] = useState<React.CSSProperties>({});
102+
const [preventClick, setPreventClick] = useState(false);
103+
104+
const containerRef = useRef<HTMLDivElement | null>(null);
105+
const animationFrameRef = useRef<number | null>(null);
106+
const targetPosRef = useRef<Position>({ x: 0, y: 0 });
107+
const currentPosRef = useRef<Position>({ x: 0, y: 0 });
108+
const lastPosRef = useRef<Position>({ x: 0, y: 0 });
109+
const velocityRef = useRef<Position>({ x: 0, y: 0 });
110+
const startPosRef = useRef<Position>({ x: 0, y: 0 });
111+
const startOffsetRef = useRef<Position>({ x: 0, y: 0 });
112+
const lastTimeRef = useRef<number>(0);
113+
const hasStartedDraggingRef = useRef<boolean>(false);
114+
115+
// Animation loop for lerp-based dragging
116+
const animate = useCallback(() => {
117+
const current = currentPosRef.current;
118+
const target = targetPosRef.current;
119+
120+
// Lerp current position towards target
121+
current.x = lerp(current.x, target.x, LERP_FACTOR);
122+
current.y = lerp(current.y, target.y, LERP_FACTOR);
123+
124+
// Calculate velocity from position delta
125+
const now = performance.now();
126+
const deltaTime = Math.max(now - lastTimeRef.current, 1); // Prevent division by zero
127+
const deltaX = current.x - lastPosRef.current.x;
128+
const deltaY = current.y - lastPosRef.current.y;
129+
130+
velocityRef.current.x = deltaX / (deltaTime / 16.67); // Normalize to 60fps
131+
velocityRef.current.y = deltaY / (deltaTime / 16.67);
132+
133+
lastPosRef.current = { ...current };
134+
lastTimeRef.current = now;
135+
136+
// Update position style
137+
setDragStyle({
138+
position: 'fixed',
139+
left: `${current.x}px`,
140+
top: `${current.y}px`,
141+
transition: 'none', // No transition during drag
142+
});
143+
144+
animationFrameRef.current = requestAnimationFrame(animate);
145+
}, []);
146+
147+
// Start drag
148+
const handlePointerDown: PointerEventHandler = useCallback(
149+
e => {
150+
// Only allow dragging on the button/header area, not on links
151+
const target = e.target as HTMLElement;
152+
if (target.tagName === 'A' || target.closest('a')) {
153+
return;
154+
}
155+
156+
const container = containerRef.current;
157+
if (!container) return;
158+
159+
const rect = container.getBoundingClientRect();
160+
const startX = e.clientX;
161+
const startY = e.clientY;
162+
163+
// Initialize positions
164+
startPosRef.current = { x: startX, y: startY };
165+
startOffsetRef.current = { x: rect.left, y: rect.top };
166+
currentPosRef.current = { x: rect.left, y: rect.top };
167+
targetPosRef.current = { x: rect.left, y: rect.top };
168+
lastPosRef.current = { x: rect.left, y: rect.top };
169+
velocityRef.current = { x: 0, y: 0 };
170+
lastTimeRef.current = performance.now();
171+
hasStartedDraggingRef.current = false;
172+
173+
// Handle pointer move
174+
const handlePointerMove = (moveEvent: PointerEvent) => {
175+
const deltaX = moveEvent.clientX - startPosRef.current.x;
176+
const deltaY = moveEvent.clientY - startPosRef.current.y;
177+
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
178+
179+
// Only start dragging if moved beyond threshold
180+
if (!hasStartedDraggingRef.current && distance < DRAG_THRESHOLD) {
181+
return;
182+
}
183+
184+
if (!hasStartedDraggingRef.current) {
185+
// Start dragging now
186+
hasStartedDraggingRef.current = true;
187+
setIsDragging(true);
188+
// Start animation loop
189+
animationFrameRef.current = requestAnimationFrame(animate);
190+
}
191+
192+
moveEvent.preventDefault();
193+
targetPosRef.current = {
194+
x: startOffsetRef.current.x + deltaX,
195+
y: startOffsetRef.current.y + deltaY,
196+
};
197+
};
198+
199+
// Handle pointer up
200+
const handlePointerUp = () => {
201+
window.removeEventListener('pointermove', handlePointerMove);
202+
window.removeEventListener('pointerup', handlePointerUp);
203+
204+
// Stop animation loop
205+
if (animationFrameRef.current !== null) {
206+
cancelAnimationFrame(animationFrameRef.current);
207+
animationFrameRef.current = null;
208+
}
209+
210+
// Only process drag end if we actually started dragging
211+
if (hasStartedDraggingRef.current) {
212+
setIsDragging(false);
213+
setPreventClick(true);
214+
215+
// Project final position with inertia
216+
const current = currentPosRef.current;
217+
const velocity = velocityRef.current;
218+
const projectedX = current.x + velocity.x * INERTIA_MULTIPLIER;
219+
const projectedY = current.y + velocity.y * INERTIA_MULTIPLIER;
220+
221+
// Determine target corner
222+
const newCorner = getCornerFromPosition(projectedX, projectedY);
223+
224+
// Get the target corner position in pixels for smooth transition
225+
const rect = container.getBoundingClientRect();
226+
const targetPos = getCornerPositionInPixels(newCorner, rect.width, rect.height);
227+
228+
// Animate to corner position smoothly
229+
setDragStyle({
230+
position: 'fixed',
231+
left: `${targetPos.x}px`,
232+
top: `${targetPos.y}px`,
233+
transition: 'all 400ms cubic-bezier(0.2, 0, 0.2, 1)', // Smooth ease-in-out
234+
});
235+
236+
// Update corner and save preference
237+
setCorner(newCorner);
238+
saveCornerPreference(newCorner);
239+
240+
// After transition completes, switch to corner-based positioning
241+
setTimeout(() => {
242+
setDragStyle({});
243+
setPreventClick(false);
244+
}, 400); // Match transition duration
245+
}
246+
247+
hasStartedDraggingRef.current = false;
248+
};
249+
250+
window.addEventListener('pointermove', handlePointerMove);
251+
window.addEventListener('pointerup', handlePointerUp, { once: true });
252+
},
253+
[animate],
254+
);
255+
256+
// Cleanup animation frame on unmount
257+
useEffect(() => {
258+
return () => {
259+
if (animationFrameRef.current !== null) {
260+
cancelAnimationFrame(animationFrameRef.current);
261+
}
262+
};
263+
}, []);
264+
265+
// Combine corner styles with drag styles
266+
const style: React.CSSProperties = {
267+
...getCornerStyles(corner),
268+
...dragStyle, // Always apply dragStyle (empty when not dragging/snapping)
269+
transition: isDragging ? 'none' : dragStyle.transition || 'all 250ms cubic-bezier(0.2, 0, 0.2, 1)', // Use dragStyle transition if present
270+
};
271+
272+
return {
273+
corner,
274+
isDragging,
275+
style,
276+
containerRef,
277+
onPointerDown: handlePointerDown,
278+
preventClick,
279+
};
280+
};

0 commit comments

Comments
 (0)