react-edge-dock 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 [Your Name]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # react-edge-dock
2
+
3
+ A zero-dependency React + TypeScript library for customizable draggable edge-docked floating buttons with popup support.
4
+
5
+ ## Features
6
+
7
+ - 🎯 Zero dependencies (React only)
8
+ - 🎨 Fully customizable and headless
9
+ - 📱 Touch and pointer event support
10
+ - 🎬 Smooth animations with spring physics
11
+ - 📦 TypeScript first with full type safety
12
+ - 🎮 Multiple docking modes (free, auto, manual)
13
+ - 💡 Smart popup positioning
14
+ - âš¡ Performance optimized with transform: translate3d
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install react-edge-dock
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```tsx
25
+ import { EdgeDock } from 'react-edge-dock';
26
+
27
+ function App() {
28
+ return (
29
+ <EdgeDock
30
+ dockMode="auto"
31
+ animation={true}
32
+ button={<button>🚀</button>}
33
+ popup={<div>Your content here</div>}
34
+ />
35
+ );
36
+ }
37
+ ```
38
+
39
+ ## API
40
+
41
+ See the [example.tsx](./example.tsx) file for more detailed usage examples.
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+ import type { EdgeDockProps } from './types';
3
+ /**
4
+ * EdgeDock - A customizable draggable edge-docked floating button with popup
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * <EdgeDock
9
+ * dockMode="auto"
10
+ * animation={true}
11
+ * button={<button>Click me</button>}
12
+ * popup={<div>Popup content</div>}
13
+ * />
14
+ * ```
15
+ */
16
+ export declare function EdgeDock(props: EdgeDockProps): React.JSX.Element;
17
+ //# sourceMappingURL=EdgeDock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EdgeDock.d.ts","sourceRoot":"","sources":["../src/EdgeDock.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAE7C;;;;;;;;;;;;GAYG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,qBAkF5C"}
@@ -0,0 +1,69 @@
1
+ import React from 'react';
2
+ import { useEdgeDock } from './useEdgeDock';
3
+ /**
4
+ * EdgeDock - A customizable draggable edge-docked floating button with popup
5
+ *
6
+ * @example
7
+ * ```tsx
8
+ * <EdgeDock
9
+ * dockMode="auto"
10
+ * animation={true}
11
+ * button={<button>Click me</button>}
12
+ * popup={<div>Popup content</div>}
13
+ * />
14
+ * ```
15
+ */
16
+ export function EdgeDock(props) {
17
+ const { button, popup, className, style, dockMode, dockEdge, position, animation, popupGap, zIndex, onDockChange, isPopupOpen, onPopupChange, } = props;
18
+ const { state, buttonRef, popupRef, closePopup, buttonStyles, popupStyles, buttonProps, } = useEdgeDock({
19
+ dockMode,
20
+ dockEdge,
21
+ position,
22
+ animation,
23
+ popupGap,
24
+ zIndex,
25
+ onDockChange,
26
+ isPopupOpen,
27
+ onPopupChange,
28
+ });
29
+ // Render button content
30
+ const renderButton = () => {
31
+ if (typeof button === 'function') {
32
+ return button(state);
33
+ }
34
+ return button || React.createElement(DefaultButton, null);
35
+ };
36
+ // Render popup content
37
+ const renderPopup = () => {
38
+ if (!popup)
39
+ return null;
40
+ if (typeof popup === 'function') {
41
+ return popup(state, closePopup);
42
+ }
43
+ return popup;
44
+ };
45
+ return (React.createElement(React.Fragment, null,
46
+ React.createElement("div", { ref: buttonRef, className: className, style: style ? { ...buttonStyles, ...style } : buttonStyles, onPointerDown: buttonProps.onPointerDown, onClick: buttonProps.onClick },
47
+ React.createElement("div", { style: buttonProps.style }, renderButton())),
48
+ popup && (React.createElement("div", { ref: popupRef, style: popupStyles, onClick: (e) => e.stopPropagation() }, renderPopup()))));
49
+ }
50
+ /**
51
+ * Default button component
52
+ */
53
+ function DefaultButton() {
54
+ return (React.createElement("div", { style: {
55
+ width: 48,
56
+ height: 48,
57
+ borderRadius: '50%',
58
+ backgroundColor: '#0070f3',
59
+ color: 'white',
60
+ display: 'flex',
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ fontSize: 24,
64
+ fontWeight: 'bold',
65
+ boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
66
+ cursor: 'pointer',
67
+ userSelect: 'none',
68
+ } }, "\u26A1"));
69
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * react-edge-dock
3
+ * A zero-dependency React + TypeScript library for customizable draggable edge-docked floating buttons
4
+ */
5
+ export { EdgeDock } from './EdgeDock';
6
+ export { useEdgeDock } from './useEdgeDock';
7
+ export type { DockEdge, DockMode, Position, DockState, EdgeDockConfig, EdgeDockProps, UseEdgeDockReturn, ViewportBounds, ElementDimensions, } from './types';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,cAAc,EACd,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,iBAAiB,GAClB,MAAM,SAAS,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * react-edge-dock
3
+ * A zero-dependency React + TypeScript library for customizable draggable edge-docked floating buttons
4
+ */
5
+ export { EdgeDock } from './EdgeDock';
6
+ export { useEdgeDock } from './useEdgeDock';
@@ -0,0 +1,109 @@
1
+ import { ReactNode } from 'react';
2
+ /**
3
+ * Dock edge position
4
+ */
5
+ export type DockEdge = 'left' | 'right' | 'top' | 'bottom';
6
+ /**
7
+ * Dock mode determines how the button snaps
8
+ */
9
+ export type DockMode = 'free' | 'auto' | 'manual';
10
+ /**
11
+ * 2D position coordinates
12
+ */
13
+ export interface Position {
14
+ x: number;
15
+ y: number;
16
+ }
17
+ /**
18
+ * Dock state information
19
+ */
20
+ export interface DockState {
21
+ /** Current position in pixels */
22
+ position: Position;
23
+ /** Current docked edge (null if in free mode) */
24
+ dockedEdge: DockEdge | null;
25
+ /** Whether the button is currently being dragged */
26
+ isDragging: boolean;
27
+ /** Whether the popup is currently open */
28
+ isPopupOpen: boolean;
29
+ }
30
+ /**
31
+ * Configuration for EdgeDock component
32
+ */
33
+ export interface EdgeDockConfig {
34
+ /** Docking behavior mode */
35
+ dockMode?: DockMode;
36
+ /** Fixed edge for manual dock mode */
37
+ dockEdge?: DockEdge;
38
+ /** Initial or controlled position */
39
+ position?: Position;
40
+ /** Enable snap animations */
41
+ animation?: boolean;
42
+ /** Gap between button and popup in pixels */
43
+ popupGap?: number;
44
+ /** z-index for the dock container */
45
+ zIndex?: number;
46
+ /** Callback when dock state changes */
47
+ onDockChange?: (state: DockState) => void;
48
+ /** Controlled popup open state */
49
+ isPopupOpen?: boolean;
50
+ /** Callback when popup state changes */
51
+ onPopupChange?: (isOpen: boolean) => void;
52
+ }
53
+ /**
54
+ * Props for EdgeDock component
55
+ */
56
+ export interface EdgeDockProps extends EdgeDockConfig {
57
+ /** Custom button element or render prop */
58
+ button?: ReactNode | ((state: DockState) => ReactNode);
59
+ /** Custom popup content or render prop */
60
+ popup?: ReactNode | ((state: DockState, close: () => void) => ReactNode);
61
+ /** Additional CSS class for container */
62
+ className?: string;
63
+ /** Additional inline styles for container */
64
+ style?: React.CSSProperties;
65
+ }
66
+ /**
67
+ * Return type for useEdgeDock hook
68
+ */
69
+ export interface UseEdgeDockReturn {
70
+ /** Current dock state */
71
+ state: DockState;
72
+ /** Ref to attach to draggable button element */
73
+ buttonRef: React.RefObject<HTMLDivElement>;
74
+ /** Ref to attach to popup element */
75
+ popupRef: React.RefObject<HTMLDivElement>;
76
+ /** Toggle popup open/closed */
77
+ togglePopup: () => void;
78
+ /** Close popup */
79
+ closePopup: () => void;
80
+ /** Open popup */
81
+ openPopup: () => void;
82
+ /** Set position programmatically */
83
+ setPosition: (position: Position) => void;
84
+ /** Inline styles for button container */
85
+ buttonStyles: React.CSSProperties;
86
+ /** Inline styles for popup container */
87
+ popupStyles: React.CSSProperties;
88
+ /** Props to spread on button for drag handling */
89
+ buttonProps: {
90
+ onPointerDown: (e: React.PointerEvent) => void;
91
+ onClick: (e: React.MouseEvent) => void;
92
+ style: React.CSSProperties;
93
+ };
94
+ }
95
+ /**
96
+ * Internal viewport bounds
97
+ */
98
+ export interface ViewportBounds {
99
+ width: number;
100
+ height: number;
101
+ }
102
+ /**
103
+ * Internal element dimensions
104
+ */
105
+ export interface ElementDimensions {
106
+ width: number;
107
+ height: number;
108
+ }
109
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { EdgeDockConfig, UseEdgeDockReturn } from './types';
2
+ /**
3
+ * Main hook for edge dock functionality
4
+ */
5
+ export declare function useEdgeDock(config?: EdgeDockConfig): UseEdgeDockReturn;
6
+ //# sourceMappingURL=useEdgeDock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEdgeDock.d.ts","sourceRoot":"","sources":["../src/useEdgeDock.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,cAAc,EAId,iBAAiB,EAGlB,MAAM,SAAS,CAAC;AA4IjB;;GAEG;AACH,wBAAgB,WAAW,CAAC,MAAM,GAAE,cAAmB,GAAG,iBAAiB,CAiT1E"}
@@ -0,0 +1,356 @@
1
+ import { useRef, useState, useCallback, useEffect } from 'react';
2
+ /**
3
+ * Calculate which edge is closest to the given position
4
+ */
5
+ function getClosestEdge(pos, viewport) {
6
+ const distanceToLeft = pos.x;
7
+ const distanceToRight = viewport.width - pos.x;
8
+ const distanceToTop = pos.y;
9
+ const distanceToBottom = viewport.height - pos.y;
10
+ const minDistance = Math.min(distanceToLeft, distanceToRight, distanceToTop, distanceToBottom);
11
+ if (minDistance === distanceToLeft)
12
+ return 'left';
13
+ if (minDistance === distanceToRight)
14
+ return 'right';
15
+ if (minDistance === distanceToTop)
16
+ return 'top';
17
+ return 'bottom';
18
+ }
19
+ /**
20
+ * Snap position to edge based on dock mode
21
+ */
22
+ function snapToEdge(pos, edge, viewport, buttonDimensions) {
23
+ const halfWidth = buttonDimensions.width / 2;
24
+ const halfHeight = buttonDimensions.height / 2;
25
+ switch (edge) {
26
+ case 'left':
27
+ return {
28
+ x: halfWidth,
29
+ y: Math.max(halfHeight, Math.min(viewport.height - halfHeight, pos.y)),
30
+ };
31
+ case 'right':
32
+ return {
33
+ x: viewport.width - halfWidth,
34
+ y: Math.max(halfHeight, Math.min(viewport.height - halfHeight, pos.y)),
35
+ };
36
+ case 'top':
37
+ return {
38
+ x: Math.max(halfWidth, Math.min(viewport.width - halfWidth, pos.x)),
39
+ y: halfHeight,
40
+ };
41
+ case 'bottom':
42
+ return {
43
+ x: Math.max(halfWidth, Math.min(viewport.width - halfWidth, pos.x)),
44
+ y: viewport.height - halfHeight,
45
+ };
46
+ }
47
+ }
48
+ /**
49
+ * Constrain position within viewport bounds
50
+ */
51
+ function constrainToViewport(pos, viewport, buttonDimensions) {
52
+ const halfWidth = buttonDimensions.width / 2;
53
+ const halfHeight = buttonDimensions.height / 2;
54
+ return {
55
+ x: Math.max(halfWidth, Math.min(viewport.width - halfWidth, pos.x)),
56
+ y: Math.max(halfHeight, Math.min(viewport.height - halfHeight, pos.y)),
57
+ };
58
+ }
59
+ /**
60
+ * Calculate popup position based on button position
61
+ */
62
+ function calculatePopupPosition(buttonPos, buttonDimensions, popupDimensions, viewport, gap) {
63
+ const halfButtonWidth = buttonDimensions.width / 2;
64
+ const halfButtonHeight = buttonDimensions.height / 2;
65
+ // Determine optimal placement
66
+ const spaceRight = viewport.width - (buttonPos.x + halfButtonWidth);
67
+ const spaceLeft = buttonPos.x - halfButtonWidth;
68
+ const spaceBottom = viewport.height - (buttonPos.y + halfButtonHeight);
69
+ const spaceTop = buttonPos.y - halfButtonHeight;
70
+ let x = buttonPos.x;
71
+ let y = buttonPos.y;
72
+ let origin = 'center';
73
+ // Horizontal positioning
74
+ if (spaceRight >= popupDimensions.width + gap) {
75
+ // Open to the right
76
+ x = buttonPos.x + halfButtonWidth + gap;
77
+ origin = 'left';
78
+ }
79
+ else if (spaceLeft >= popupDimensions.width + gap) {
80
+ // Open to the left
81
+ x = buttonPos.x - halfButtonWidth - gap - popupDimensions.width;
82
+ origin = 'right';
83
+ }
84
+ else {
85
+ // Center horizontally
86
+ x = Math.max(0, Math.min(viewport.width - popupDimensions.width, buttonPos.x - popupDimensions.width / 2));
87
+ }
88
+ // Vertical positioning
89
+ if (spaceBottom >= popupDimensions.height + gap) {
90
+ // Open downward
91
+ y = buttonPos.y + halfButtonHeight + gap;
92
+ origin = origin === 'left' || origin === 'right' ? `${origin} top` : 'top';
93
+ }
94
+ else if (spaceTop >= popupDimensions.height + gap) {
95
+ // Open upward
96
+ y = buttonPos.y - halfButtonHeight - gap - popupDimensions.height;
97
+ origin = origin === 'left' || origin === 'right' ? `${origin} bottom` : 'bottom';
98
+ }
99
+ else {
100
+ // Center vertically
101
+ y = Math.max(0, Math.min(viewport.height - popupDimensions.height, buttonPos.y - popupDimensions.height / 2));
102
+ }
103
+ return {
104
+ position: { x, y },
105
+ origin,
106
+ };
107
+ }
108
+ /**
109
+ * Main hook for edge dock functionality
110
+ */
111
+ export function useEdgeDock(config = {}) {
112
+ const { dockMode = 'auto', dockEdge, position: controlledPosition, animation = true, popupGap = 12, zIndex = 9999, onDockChange, isPopupOpen: controlledPopupOpen, onPopupChange, } = config;
113
+ const buttonRef = useRef(null);
114
+ const popupRef = useRef(null);
115
+ // Internal state
116
+ const [position, setPositionInternal] = useState(controlledPosition || { x: window.innerWidth - 60, y: window.innerHeight - 60 });
117
+ const [dockedEdge, setDockedEdge] = useState(null);
118
+ const [isDragging, setIsDragging] = useState(false);
119
+ const [isPopupOpenInternal, setIsPopupOpenInternal] = useState(false);
120
+ const [isAnimating, setIsAnimating] = useState(false);
121
+ const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
122
+ const [popupOrigin, setPopupOrigin] = useState('center');
123
+ // Drag state
124
+ const dragStateRef = useRef({
125
+ isDragging: false,
126
+ startX: 0,
127
+ startY: 0,
128
+ startPosX: 0,
129
+ startPosY: 0,
130
+ hasMoved: false,
131
+ });
132
+ const isPopupOpen = controlledPopupOpen ?? isPopupOpenInternal;
133
+ // Get current state
134
+ const state = {
135
+ position,
136
+ dockedEdge,
137
+ isDragging,
138
+ isPopupOpen,
139
+ };
140
+ // Update controlled position
141
+ useEffect(() => {
142
+ if (controlledPosition) {
143
+ setPositionInternal(controlledPosition);
144
+ }
145
+ }, [controlledPosition]);
146
+ // Calculate popup position when it opens or button moves
147
+ useEffect(() => {
148
+ if (isPopupOpen && buttonRef.current && popupRef.current) {
149
+ const buttonRect = buttonRef.current.getBoundingClientRect();
150
+ const popupRect = popupRef.current.getBoundingClientRect();
151
+ const result = calculatePopupPosition(position, { width: buttonRect.width, height: buttonRect.height }, { width: popupRect.width, height: popupRect.height }, { width: window.innerWidth, height: window.innerHeight }, popupGap);
152
+ setPopupPosition(result.position);
153
+ setPopupOrigin(result.origin);
154
+ }
155
+ }, [isPopupOpen, position, popupGap]);
156
+ // Notify state changes
157
+ useEffect(() => {
158
+ if (onDockChange) {
159
+ onDockChange(state);
160
+ }
161
+ }, [position, dockedEdge, isDragging, isPopupOpen]);
162
+ // Set position with constraints
163
+ const setPosition = useCallback((newPos) => {
164
+ if (!buttonRef.current)
165
+ return;
166
+ const buttonRect = buttonRef.current.getBoundingClientRect();
167
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
168
+ const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
169
+ let finalPos = constrainToViewport(newPos, viewport, buttonDimensions);
170
+ if (dockMode === 'auto') {
171
+ const edge = getClosestEdge(finalPos, viewport);
172
+ finalPos = snapToEdge(finalPos, edge, viewport, buttonDimensions);
173
+ setDockedEdge(edge);
174
+ }
175
+ else if (dockMode === 'manual' && dockEdge) {
176
+ finalPos = snapToEdge(finalPos, dockEdge, viewport, buttonDimensions);
177
+ setDockedEdge(dockEdge);
178
+ }
179
+ else {
180
+ setDockedEdge(null);
181
+ }
182
+ setPositionInternal(finalPos);
183
+ }, [dockMode, dockEdge]);
184
+ // Toggle popup
185
+ const togglePopup = useCallback(() => {
186
+ const newState = !isPopupOpen;
187
+ if (controlledPopupOpen === undefined) {
188
+ setIsPopupOpenInternal(newState);
189
+ }
190
+ onPopupChange?.(newState);
191
+ }, [isPopupOpen, controlledPopupOpen, onPopupChange]);
192
+ const closePopup = useCallback(() => {
193
+ if (controlledPopupOpen === undefined) {
194
+ setIsPopupOpenInternal(false);
195
+ }
196
+ onPopupChange?.(false);
197
+ }, [controlledPopupOpen, onPopupChange]);
198
+ const openPopup = useCallback(() => {
199
+ if (controlledPopupOpen === undefined) {
200
+ setIsPopupOpenInternal(true);
201
+ }
202
+ onPopupChange?.(true);
203
+ }, [controlledPopupOpen, onPopupChange]);
204
+ // Pointer down handler
205
+ const handlePointerDown = useCallback((e) => {
206
+ if (!buttonRef.current)
207
+ return;
208
+ e.preventDefault();
209
+ e.stopPropagation();
210
+ const target = e.currentTarget;
211
+ target.setPointerCapture(e.pointerId);
212
+ dragStateRef.current = {
213
+ isDragging: true,
214
+ startX: e.clientX,
215
+ startY: e.clientY,
216
+ startPosX: position.x,
217
+ startPosY: position.y,
218
+ hasMoved: false,
219
+ };
220
+ setIsDragging(true);
221
+ setIsAnimating(false);
222
+ // Close popup when starting to drag
223
+ if (isPopupOpen) {
224
+ closePopup();
225
+ }
226
+ }, [position, isPopupOpen, closePopup]);
227
+ // Pointer move handler
228
+ useEffect(() => {
229
+ const handlePointerMove = (e) => {
230
+ if (!dragStateRef.current.isDragging || !buttonRef.current)
231
+ return;
232
+ const deltaX = e.clientX - dragStateRef.current.startX;
233
+ const deltaY = e.clientY - dragStateRef.current.startY;
234
+ // Mark as moved if dragged more than 5px
235
+ if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
236
+ dragStateRef.current.hasMoved = true;
237
+ }
238
+ const newPos = {
239
+ x: dragStateRef.current.startPosX + deltaX,
240
+ y: dragStateRef.current.startPosY + deltaY,
241
+ };
242
+ const buttonRect = buttonRef.current.getBoundingClientRect();
243
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
244
+ const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
245
+ // During drag, only constrain to viewport (no snapping)
246
+ const constrainedPos = constrainToViewport(newPos, viewport, buttonDimensions);
247
+ setPositionInternal(constrainedPos);
248
+ };
249
+ const handlePointerUp = () => {
250
+ if (!dragStateRef.current.isDragging)
251
+ return;
252
+ dragStateRef.current.isDragging = false;
253
+ setIsDragging(false);
254
+ // Apply snapping after drag ends
255
+ if (animation) {
256
+ setIsAnimating(true);
257
+ }
258
+ // Snap to edge if needed
259
+ if (buttonRef.current) {
260
+ const buttonRect = buttonRef.current.getBoundingClientRect();
261
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
262
+ const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
263
+ let finalPos = position;
264
+ if (dockMode === 'auto') {
265
+ const edge = getClosestEdge(position, viewport);
266
+ finalPos = snapToEdge(position, edge, viewport, buttonDimensions);
267
+ setDockedEdge(edge);
268
+ }
269
+ else if (dockMode === 'manual' && dockEdge) {
270
+ finalPos = snapToEdge(position, dockEdge, viewport, buttonDimensions);
271
+ setDockedEdge(dockEdge);
272
+ }
273
+ setPositionInternal(finalPos);
274
+ }
275
+ };
276
+ document.addEventListener('pointermove', handlePointerMove);
277
+ document.addEventListener('pointerup', handlePointerUp);
278
+ return () => {
279
+ document.removeEventListener('pointermove', handlePointerMove);
280
+ document.removeEventListener('pointerup', handlePointerUp);
281
+ };
282
+ }, [position, dockMode, dockEdge, animation]);
283
+ // Click handler (only trigger if not dragged)
284
+ const handleClick = useCallback((e) => {
285
+ if (dragStateRef.current.hasMoved) {
286
+ e.preventDefault();
287
+ e.stopPropagation();
288
+ return;
289
+ }
290
+ togglePopup();
291
+ }, [togglePopup]);
292
+ // Handle window resize
293
+ useEffect(() => {
294
+ const handleResize = () => {
295
+ if (buttonRef.current) {
296
+ const buttonRect = buttonRef.current.getBoundingClientRect();
297
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
298
+ const buttonDimensions = { width: buttonRect.width, height: buttonRect.height };
299
+ let newPos = constrainToViewport(position, viewport, buttonDimensions);
300
+ if (dockMode === 'auto') {
301
+ const edge = getClosestEdge(newPos, viewport);
302
+ newPos = snapToEdge(newPos, edge, viewport, buttonDimensions);
303
+ setDockedEdge(edge);
304
+ }
305
+ else if (dockMode === 'manual' && dockEdge) {
306
+ newPos = snapToEdge(newPos, dockEdge, viewport, buttonDimensions);
307
+ }
308
+ setPositionInternal(newPos);
309
+ }
310
+ };
311
+ window.addEventListener('resize', handleResize);
312
+ return () => window.removeEventListener('resize', handleResize);
313
+ }, [position, dockMode, dockEdge]);
314
+ // Button styles
315
+ const buttonStyles = {
316
+ position: 'fixed',
317
+ left: 0,
318
+ top: 0,
319
+ transform: `translate3d(${position.x}px, ${position.y}px, 0) translate(-50%, -50%)`,
320
+ transition: isAnimating && animation ? 'transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)' : 'none',
321
+ cursor: isDragging ? 'grabbing' : 'grab',
322
+ touchAction: 'none',
323
+ userSelect: 'none',
324
+ zIndex,
325
+ willChange: isDragging ? 'transform' : 'auto',
326
+ };
327
+ // Popup styles
328
+ const popupStyles = {
329
+ position: 'fixed',
330
+ left: popupPosition.x,
331
+ top: popupPosition.y,
332
+ zIndex: zIndex + 1,
333
+ opacity: isPopupOpen ? 1 : 0,
334
+ pointerEvents: isPopupOpen ? 'auto' : 'none',
335
+ transformOrigin: popupOrigin,
336
+ transform: isPopupOpen ? 'scale(1)' : 'scale(0.95)',
337
+ transition: animation ? 'opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)' : 'none',
338
+ };
339
+ const buttonProps = {
340
+ onPointerDown: handlePointerDown,
341
+ onClick: handleClick,
342
+ style: { userSelect: 'none', touchAction: 'none' },
343
+ };
344
+ return {
345
+ state,
346
+ buttonRef,
347
+ popupRef,
348
+ togglePopup,
349
+ closePopup,
350
+ openPopup,
351
+ setPosition,
352
+ buttonStyles,
353
+ popupStyles,
354
+ buttonProps,
355
+ };
356
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "react-edge-dock",
3
+ "version": "1.0.0",
4
+ "description": "A zero-dependency React TypeScript library for customizable draggable edge-docked floating buttons with popup support",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.esm.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/yourusername/react-edge-dock.git"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/yourusername/react-edge-dock/issues"
18
+ },
19
+ "homepage": "https://github.com/yourusername/react-edge-dock#readme",
20
+ "scripts": {
21
+ "build": "tsc",
22
+ "dev": "tsc --watch",
23
+ "clean": "rm -rf dist",
24
+ "prepublishOnly": "npm run clean && npm run build",
25
+ "prepack": "npm run build"
26
+ },
27
+ "keywords": [
28
+ "react",
29
+ "typescript",
30
+ "draggable",
31
+ "floating-button",
32
+ "edge-dock",
33
+ "popup",
34
+ "overlay",
35
+ "headless"
36
+ ],
37
+ "author": "",
38
+ "license": "MIT",
39
+ "peerDependencies": {
40
+ "react": "^18.0.0 || ^19.0.0",
41
+ "react-dom": "^18.0.0 || ^19.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^18.2.0",
45
+ "@types/react-dom": "^18.2.0",
46
+ "typescript": "^5.3.0"
47
+ }
48
+ }