@cleartrip/ct-design-floating-wrapper 1.11.0 → 5.27.0-SNAPSHOT-native-main.14
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/dist/FloatingWrapper.d.ts +2 -3
- package/dist/FloatingWrapper.d.ts.map +1 -1
- package/dist/FloatingWrapper.native.d.ts +4 -0
- package/dist/FloatingWrapper.native.d.ts.map +1 -0
- package/dist/ct-design-floating-wrapper.browser.cjs.js +1 -1
- package/dist/ct-design-floating-wrapper.browser.cjs.js.map +1 -1
- package/dist/ct-design-floating-wrapper.browser.esm.js +1 -1
- package/dist/ct-design-floating-wrapper.browser.esm.js.map +1 -1
- package/dist/ct-design-floating-wrapper.cjs.js +99 -105
- package/dist/ct-design-floating-wrapper.cjs.js.map +1 -1
- package/dist/ct-design-floating-wrapper.esm.js +100 -106
- package/dist/ct-design-floating-wrapper.esm.js.map +1 -1
- package/dist/ct-design-floating-wrapper.umd.js +102 -137
- package/dist/ct-design-floating-wrapper.umd.js.map +1 -1
- package/dist/hooks/useDraggable.d.ts.map +1 -1
- package/dist/hooks/useDraggable.native.d.ts +10 -0
- package/dist/hooks/useDraggable.native.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.native.d.ts +4 -0
- package/dist/index.native.d.ts.map +1 -0
- package/dist/style.d.ts +2 -2
- package/dist/style.d.ts.map +1 -1
- package/dist/type.d.ts +2 -2
- package/dist/type.d.ts.map +1 -1
- package/package.json +32 -6
- package/src/FloatingWrapper.native.tsx +46 -0
- package/src/FloatingWrapper.tsx +54 -0
- package/src/hooks/useDraggable.native.ts +191 -0
- package/src/hooks/useDraggable.ts +295 -0
- package/src/index.native.ts +3 -0
- package/src/index.ts +3 -0
- package/src/style.ts +18 -0
- package/src/type.ts +86 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Animated from 'react-native-reanimated';
|
|
2
|
+
import { GestureDetector } from 'react-native-gesture-handler';
|
|
3
|
+
import { makeStyles } from '@cleartrip/ct-design-style-manager';
|
|
4
|
+
|
|
5
|
+
import { useDraggable } from './hooks/useDraggable.native';
|
|
6
|
+
import type { IFloatingWrapperProps } from './type';
|
|
7
|
+
|
|
8
|
+
const staticStyles = makeStyles(() => ({
|
|
9
|
+
root: {
|
|
10
|
+
position: 'absolute',
|
|
11
|
+
top: 0,
|
|
12
|
+
left: 0,
|
|
13
|
+
zIndex: 100,
|
|
14
|
+
},
|
|
15
|
+
hidden: {
|
|
16
|
+
display: 'none',
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Native counterpart of the web `FloatingWrapper`. Renders a draggable,
|
|
22
|
+
* edge-snapping overlay. There is no `position: fixed` in React Native, so the
|
|
23
|
+
* widget is `position: absolute` and must be mounted inside a full-screen parent
|
|
24
|
+
* (or the design-system `Portal`) for a true overlay. The app root must also be
|
|
25
|
+
* wrapped in `GestureHandlerRootView`.
|
|
26
|
+
*/
|
|
27
|
+
const FloatingWrapper = (props: IFloatingWrapperProps) => {
|
|
28
|
+
const { children, show = true } = props;
|
|
29
|
+
const { gesture, animatedStyle, onLayout } = useDraggable(props);
|
|
30
|
+
|
|
31
|
+
// Stay mounted while hidden (via `display: none`) so the dragged coordinates
|
|
32
|
+
// are retained and the widget re-opens where the user last left it.
|
|
33
|
+
return (
|
|
34
|
+
<GestureDetector gesture={gesture}>
|
|
35
|
+
<Animated.View
|
|
36
|
+
onLayout={onLayout}
|
|
37
|
+
pointerEvents={show ? 'auto' : 'none'}
|
|
38
|
+
style={[staticStyles.root, animatedStyle, !show && staticStyles.hidden]}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</Animated.View>
|
|
42
|
+
</GestureDetector>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export default FloatingWrapper;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type CSSProperties } from 'react';
|
|
2
|
+
import { makeStyles, useWebMergeStyles } from '@cleartrip/ct-design-style-manager';
|
|
3
|
+
|
|
4
|
+
import { useDraggable } from './hooks/useDraggable';
|
|
5
|
+
import type { IFloatingWrapperProps } from './type';
|
|
6
|
+
|
|
7
|
+
const staticStyles = makeStyles(() => ({
|
|
8
|
+
root: {
|
|
9
|
+
position: 'fixed',
|
|
10
|
+
top: 0,
|
|
11
|
+
left: 0,
|
|
12
|
+
willChange: 'transform',
|
|
13
|
+
touchAction: 'none',
|
|
14
|
+
userSelect: 'none',
|
|
15
|
+
WebkitUserSelect: 'none',
|
|
16
|
+
WebkitTapHighlightColor: 'transparent',
|
|
17
|
+
zIndex: 100,
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const FloatingWrapper = (props: IFloatingWrapperProps) => {
|
|
22
|
+
const { children, disabled = false, show = true } = props;
|
|
23
|
+
|
|
24
|
+
const { bind, position, isReady, canAnimate } = useDraggable(props);
|
|
25
|
+
|
|
26
|
+
const rootClassName = useWebMergeStyles([staticStyles.root], []);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resting style only. The drag path (cursor / transition / transform) is
|
|
30
|
+
* written straight to the element by `useDraggable` to avoid re-renders.
|
|
31
|
+
*/
|
|
32
|
+
const dynamicStyle: CSSProperties = {
|
|
33
|
+
transform: `translate(${position.x}px, ${position.y}px)`,
|
|
34
|
+
cursor: disabled ? 'default' : 'grab',
|
|
35
|
+
// Skip the transition for the very first placement so the widget shows
|
|
36
|
+
// up at its resting corner instead of sliding in from `(0,0)`.
|
|
37
|
+
transition: canAnimate ? 'transform 200ms ease-out' : 'none',
|
|
38
|
+
// Keep it hidden until measured/positioned to avoid a `(0,0)` flash.
|
|
39
|
+
visibility: isReady ? undefined : 'hidden',
|
|
40
|
+
// Hide via CSS (instead of unmounting) so the dragged coordinates are
|
|
41
|
+
// retained and the widget re-opens where the user last left it.
|
|
42
|
+
display: show ? undefined : 'none',
|
|
43
|
+
// Disable interaction while hidden so it can't be clicked.
|
|
44
|
+
pointerEvents: show ? undefined : 'none',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div {...bind} className={rootClassName} style={dynamicStyle}>
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export default FloatingWrapper;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useCallback, useEffect } from 'react';
|
|
2
|
+
import { Dimensions, LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { Gesture, GestureType } from 'react-native-gesture-handler';
|
|
4
|
+
import { AnimatedStyle, Easing, runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
|
5
|
+
|
|
6
|
+
import { IBounds, IFloatingWrapperProps, IPosition, InitialCorner } from '../type';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_EDGE_OFFSET = 8;
|
|
9
|
+
const SNAP_DURATION = 200;
|
|
10
|
+
|
|
11
|
+
const clampJS = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
|
12
|
+
|
|
13
|
+
const isExplicitBounds = (bounds: IFloatingWrapperProps['bounds']): bounds is IBounds =>
|
|
14
|
+
typeof bounds === 'object' && bounds !== null && 'top' in bounds;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* React Native drag/position engine behind `FloatingWrapper`. Mirrors the web
|
|
18
|
+
* `useDraggable`: tracks an `{ x, y }` translation, keeps the element inside its
|
|
19
|
+
* `bounds`, and (optionally) snaps to the nearest horizontal edge on release.
|
|
20
|
+
*
|
|
21
|
+
* The web build writes transforms straight to the DOM node; here the gesture math
|
|
22
|
+
* runs on the UI thread via Reanimated shared values, so React never re-renders
|
|
23
|
+
* during a drag.
|
|
24
|
+
*/
|
|
25
|
+
export const useDraggable = (
|
|
26
|
+
props: IFloatingWrapperProps,
|
|
27
|
+
): { gesture: GestureType; animatedStyle: AnimatedStyle; onLayout: (event: LayoutChangeEvent) => void } => {
|
|
28
|
+
const {
|
|
29
|
+
bounds = 'viewport',
|
|
30
|
+
edgeOffset = DEFAULT_EDGE_OFFSET,
|
|
31
|
+
initialPosition = InitialCorner.BOTTOM_RIGHT,
|
|
32
|
+
snapToEdge = true,
|
|
33
|
+
disabled = false,
|
|
34
|
+
onTap,
|
|
35
|
+
} = props;
|
|
36
|
+
|
|
37
|
+
const tx = useSharedValue(0);
|
|
38
|
+
const ty = useSharedValue(0);
|
|
39
|
+
const startX = useSharedValue(0);
|
|
40
|
+
const startY = useSharedValue(0);
|
|
41
|
+
const width = useSharedValue(0);
|
|
42
|
+
const height = useSharedValue(0);
|
|
43
|
+
const ready = useSharedValue(false);
|
|
44
|
+
|
|
45
|
+
// Bounds + config mirrored into shared values so the pan worklet can read
|
|
46
|
+
// them on the UI thread without hopping back to JS.
|
|
47
|
+
const boundLeft = useSharedValue(0);
|
|
48
|
+
const boundTop = useSharedValue(0);
|
|
49
|
+
const boundRight = useSharedValue(0);
|
|
50
|
+
const boundBottom = useSharedValue(0);
|
|
51
|
+
const edge = useSharedValue(edgeOffset);
|
|
52
|
+
const snap = useSharedValue(snapToEdge);
|
|
53
|
+
|
|
54
|
+
const readRect = useCallback((): IBounds => {
|
|
55
|
+
if (isExplicitBounds(bounds)) {
|
|
56
|
+
return bounds;
|
|
57
|
+
}
|
|
58
|
+
// NOTE: element-ref bounds need an async measureInWindow pass; until that
|
|
59
|
+
// is wired up we fall back to the full viewport.
|
|
60
|
+
const { width: vw, height: vh } = Dimensions.get('window');
|
|
61
|
+
return { top: 0, left: 0, right: vw, bottom: vh };
|
|
62
|
+
}, [bounds]);
|
|
63
|
+
|
|
64
|
+
const syncBounds = useCallback(() => {
|
|
65
|
+
const rect = readRect();
|
|
66
|
+
boundLeft.value = rect.left;
|
|
67
|
+
boundTop.value = rect.top;
|
|
68
|
+
boundRight.value = rect.right;
|
|
69
|
+
boundBottom.value = rect.bottom;
|
|
70
|
+
}, [readRect, boundLeft, boundTop, boundRight, boundBottom]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
edge.value = edgeOffset;
|
|
74
|
+
snap.value = snapToEdge;
|
|
75
|
+
}, [edgeOffset, snapToEdge, edge, snap]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
syncBounds();
|
|
79
|
+
const subscription = Dimensions.addEventListener('change', syncBounds);
|
|
80
|
+
return () => subscription.remove();
|
|
81
|
+
}, [syncBounds]);
|
|
82
|
+
|
|
83
|
+
const onLayout = useCallback(
|
|
84
|
+
(event: LayoutChangeEvent) => {
|
|
85
|
+
const { width: w, height: h } = event.nativeEvent.layout;
|
|
86
|
+
// A hidden widget (`display: none` while `show` is false) measures 0x0.
|
|
87
|
+
// Keep the last real size so limits don't collapse to a zero-size box.
|
|
88
|
+
if (w === 0 && h === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const firstMeasure = width.value === 0 && height.value === 0;
|
|
93
|
+
width.value = w;
|
|
94
|
+
height.value = h;
|
|
95
|
+
|
|
96
|
+
if (!firstMeasure) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rect = readRect();
|
|
101
|
+
const minX = rect.left + edgeOffset;
|
|
102
|
+
const minY = rect.top + edgeOffset;
|
|
103
|
+
const maxX = Math.max(minX, rect.right - w - edgeOffset);
|
|
104
|
+
const maxY = Math.max(minY, rect.bottom - h - edgeOffset);
|
|
105
|
+
|
|
106
|
+
let resting: IPosition;
|
|
107
|
+
if (typeof initialPosition !== 'string') {
|
|
108
|
+
resting = {
|
|
109
|
+
x: clampJS(initialPosition.x, minX, maxX),
|
|
110
|
+
y: clampJS(initialPosition.y, minY, maxY),
|
|
111
|
+
};
|
|
112
|
+
} else {
|
|
113
|
+
switch (initialPosition) {
|
|
114
|
+
case InitialCorner.TOP_LEFT:
|
|
115
|
+
resting = { x: minX, y: minY };
|
|
116
|
+
break;
|
|
117
|
+
case InitialCorner.TOP_RIGHT:
|
|
118
|
+
resting = { x: maxX, y: minY };
|
|
119
|
+
break;
|
|
120
|
+
case InitialCorner.BOTTOM_LEFT:
|
|
121
|
+
resting = { x: minX, y: maxY };
|
|
122
|
+
break;
|
|
123
|
+
case InitialCorner.BOTTOM_RIGHT:
|
|
124
|
+
default:
|
|
125
|
+
resting = { x: maxX, y: maxY };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
tx.value = resting.x;
|
|
130
|
+
ty.value = resting.y;
|
|
131
|
+
ready.value = true;
|
|
132
|
+
},
|
|
133
|
+
[readRect, edgeOffset, initialPosition, width, height, tx, ty, ready],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const panGesture = Gesture.Pan()
|
|
137
|
+
.enabled(!disabled)
|
|
138
|
+
.onStart(() => {
|
|
139
|
+
'worklet';
|
|
140
|
+
startX.value = tx.value;
|
|
141
|
+
startY.value = ty.value;
|
|
142
|
+
})
|
|
143
|
+
.onUpdate((gestureEvent) => {
|
|
144
|
+
'worklet';
|
|
145
|
+
const minX = boundLeft.value + edge.value;
|
|
146
|
+
const minY = boundTop.value + edge.value;
|
|
147
|
+
const maxX = Math.max(minX, boundRight.value - width.value - edge.value);
|
|
148
|
+
const maxY = Math.max(minY, boundBottom.value - height.value - edge.value);
|
|
149
|
+
tx.value = Math.min(Math.max(startX.value + gestureEvent.translationX, minX), maxX);
|
|
150
|
+
ty.value = Math.min(Math.max(startY.value + gestureEvent.translationY, minY), maxY);
|
|
151
|
+
})
|
|
152
|
+
.onEnd(() => {
|
|
153
|
+
'worklet';
|
|
154
|
+
const minX = boundLeft.value + edge.value;
|
|
155
|
+
const maxX = Math.max(minX, boundRight.value - width.value - edge.value);
|
|
156
|
+
let targetX = tx.value;
|
|
157
|
+
|
|
158
|
+
if (snap.value) {
|
|
159
|
+
const center = tx.value + width.value / 2;
|
|
160
|
+
const boxCenter = (boundLeft.value + boundRight.value) / 2;
|
|
161
|
+
targetX = center < boxCenter ? minX : maxX;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
tx.value = withTiming(targetX, {
|
|
165
|
+
duration: SNAP_DURATION,
|
|
166
|
+
easing: Easing.out(Easing.ease),
|
|
167
|
+
});
|
|
168
|
+
ty.value = withTiming(ty.value, {
|
|
169
|
+
duration: SNAP_DURATION,
|
|
170
|
+
easing: Easing.out(Easing.ease),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const tapGesture = Gesture.Tap().onEnd((_event, success) => {
|
|
175
|
+
'worklet';
|
|
176
|
+
if (success && onTap) {
|
|
177
|
+
runOnJS(onTap)();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const gesture = Gesture.Race(panGesture, tapGesture);
|
|
182
|
+
|
|
183
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
184
|
+
transform: [{ translateX: tx.value }, { translateY: ty.value }],
|
|
185
|
+
// Keep it hidden until measured/positioned to avoid a `(0,0)` flash.
|
|
186
|
+
opacity: ready.value ? 1 : 0,
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
// @ts-ignore
|
|
190
|
+
return { gesture, animatedStyle, onLayout };
|
|
191
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useSwipeable } from 'react-swipeable';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
IBounds,
|
|
6
|
+
IFloatingWrapperProps,
|
|
7
|
+
IPosition,
|
|
8
|
+
IUseDraggableResult,
|
|
9
|
+
TBoundsInput,
|
|
10
|
+
TInitialCorner,
|
|
11
|
+
InitialCorner,
|
|
12
|
+
} from '../type';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_EDGE_OFFSET = 8;
|
|
15
|
+
const SNAP_TRANSITION = 'transform 200ms ease-out';
|
|
16
|
+
|
|
17
|
+
const clamp = (value: number, min: number, max: number) => {
|
|
18
|
+
return Math.min(Math.max(value, min), max);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const isExplicitBounds = (bounds: TBoundsInput): bounds is IBounds => {
|
|
22
|
+
return typeof bounds === 'object' && bounds !== null && 'top' in bounds;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isCorner = (position: IPosition | TInitialCorner): position is TInitialCorner => typeof position === 'string';
|
|
26
|
+
|
|
27
|
+
const getViewportRect = (): IBounds => ({
|
|
28
|
+
top: 0,
|
|
29
|
+
left: 0,
|
|
30
|
+
right: typeof window === 'undefined' ? 0 : window.innerWidth,
|
|
31
|
+
bottom: typeof window === 'undefined' ? 0 : window.innerHeight,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const resolveBounds = (bounds: TBoundsInput | undefined): IBounds => {
|
|
35
|
+
if (!bounds || bounds === 'viewport') return getViewportRect();
|
|
36
|
+
if (isExplicitBounds(bounds)) return bounds;
|
|
37
|
+
|
|
38
|
+
const element = bounds.current;
|
|
39
|
+
if (!element) return getViewportRect();
|
|
40
|
+
|
|
41
|
+
const rect = element.getBoundingClientRect();
|
|
42
|
+
return {
|
|
43
|
+
top: rect.top,
|
|
44
|
+
left: rect.left,
|
|
45
|
+
right: rect.right,
|
|
46
|
+
bottom: rect.bottom,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface ILimits {
|
|
51
|
+
minX: number;
|
|
52
|
+
minY: number;
|
|
53
|
+
maxX: number;
|
|
54
|
+
maxY: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drag/position engine behind `FloatingWidget`. Tracks an `{ x, y }` translation,
|
|
59
|
+
* keeps the element inside its `bounds`, and (optionally) snaps to the nearest
|
|
60
|
+
* horizontal edge when a drag ends.
|
|
61
|
+
*/
|
|
62
|
+
export const useDraggable = (props: IFloatingWrapperProps): IUseDraggableResult => {
|
|
63
|
+
const {
|
|
64
|
+
bounds = 'viewport',
|
|
65
|
+
edgeOffset = DEFAULT_EDGE_OFFSET,
|
|
66
|
+
initialPosition = InitialCorner.BOTTOM_RIGHT,
|
|
67
|
+
snapToEdge = true,
|
|
68
|
+
disabled = false,
|
|
69
|
+
show = true,
|
|
70
|
+
onTap,
|
|
71
|
+
} = props;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* State for the position of the draggable element
|
|
75
|
+
*/
|
|
76
|
+
const [position, setPosition] = useState<IPosition>({ x: 0, y: 0 });
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Becomes `true` once the element has been measured and positioned. Used to
|
|
80
|
+
* keep it hidden until then so there's no `(0,0)` flash.
|
|
81
|
+
*/
|
|
82
|
+
const [isReady, setIsReady] = useState(false);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Enabled one frame after the first placement so the initial position is
|
|
86
|
+
* applied without a transition (no slide-in), while subsequent moves (drag
|
|
87
|
+
* snap / resize re-clamp) still animate.
|
|
88
|
+
*/
|
|
89
|
+
const [canAnimate, setCanAnimate] = useState(false);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Refs for the draggable element
|
|
93
|
+
*/
|
|
94
|
+
const elementRef = useRef<HTMLElement | null>(null);
|
|
95
|
+
/**
|
|
96
|
+
* Refs for the size of the draggable element
|
|
97
|
+
*/
|
|
98
|
+
const sizeRef = useRef({ width: 0, height: 0 });
|
|
99
|
+
/**
|
|
100
|
+
* Refs for the position of the draggable element
|
|
101
|
+
*/
|
|
102
|
+
const positionRef = useRef(position);
|
|
103
|
+
/**
|
|
104
|
+
* Refs for the start position of the draggable element
|
|
105
|
+
*/
|
|
106
|
+
const dragStartRef = useRef<IPosition>({ x: 0, y: 0 });
|
|
107
|
+
/**
|
|
108
|
+
* Refs for the initialized state of the draggable element
|
|
109
|
+
*/
|
|
110
|
+
const isInitializedRef = useRef(false);
|
|
111
|
+
/**
|
|
112
|
+
* Last viewport width, used to tell an orientation change apart from a
|
|
113
|
+
* height-only resize.
|
|
114
|
+
*/
|
|
115
|
+
const viewportWidthRef = useRef(0);
|
|
116
|
+
|
|
117
|
+
// Mirror the latest props/state into refs so the mount-only effect and the
|
|
118
|
+
// swipe handlers always read fresh values without re-subscribing.
|
|
119
|
+
positionRef.current = position;
|
|
120
|
+
const propsRef = useRef(props);
|
|
121
|
+
propsRef.current = { ...props, bounds, edgeOffset, initialPosition, snapToEdge, show };
|
|
122
|
+
|
|
123
|
+
const getLimits = (): ILimits => {
|
|
124
|
+
const rect = resolveBounds(propsRef.current.bounds);
|
|
125
|
+
const { width, height } = sizeRef.current;
|
|
126
|
+
const pad = propsRef.current.edgeOffset ?? DEFAULT_EDGE_OFFSET;
|
|
127
|
+
const minX = rect.left + pad;
|
|
128
|
+
const minY = rect.top + pad;
|
|
129
|
+
const maxX = Math.max(minX, rect.right - width - pad);
|
|
130
|
+
const maxY = Math.max(minY, rect.bottom - height - pad);
|
|
131
|
+
return { minX, minY, maxX, maxY };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const clampToLimits = (next: IPosition): IPosition => {
|
|
135
|
+
const { minX, minY, maxX, maxY } = getLimits();
|
|
136
|
+
return {
|
|
137
|
+
x: clamp(next.x, minX, maxX),
|
|
138
|
+
y: clamp(next.y, minY, maxY),
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const snapToNearestEdge = (next: IPosition): IPosition => {
|
|
143
|
+
const clamped = clampToLimits(next);
|
|
144
|
+
if (!propsRef.current.snapToEdge) return clamped;
|
|
145
|
+
|
|
146
|
+
const rect = resolveBounds(propsRef.current.bounds);
|
|
147
|
+
const { minX, maxX } = getLimits();
|
|
148
|
+
const center = clamped.x + sizeRef.current.width / 2;
|
|
149
|
+
const boxCenter = (rect.left + rect.right) / 2;
|
|
150
|
+
return { ...clamped, x: center < boxCenter ? minX : maxX };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const resolveInitialPosition = (): IPosition => {
|
|
154
|
+
const { minX, minY, maxX, maxY } = getLimits();
|
|
155
|
+
const initial = propsRef.current.initialPosition ?? InitialCorner.BOTTOM_RIGHT;
|
|
156
|
+
|
|
157
|
+
if (!isCorner(initial)) return clampToLimits(initial);
|
|
158
|
+
|
|
159
|
+
switch (initial) {
|
|
160
|
+
case InitialCorner.TOP_LEFT:
|
|
161
|
+
return { x: minX, y: minY };
|
|
162
|
+
case InitialCorner.TOP_RIGHT:
|
|
163
|
+
return { x: maxX, y: minY };
|
|
164
|
+
case InitialCorner.BOTTOM_LEFT:
|
|
165
|
+
return { x: minX, y: maxY };
|
|
166
|
+
case InitialCorner.BOTTOM_RIGHT:
|
|
167
|
+
default:
|
|
168
|
+
return { x: maxX, y: maxY };
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const applyTransform = (pos: IPosition) => {
|
|
173
|
+
const element = elementRef.current;
|
|
174
|
+
if (element) {
|
|
175
|
+
element.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const applyDragState = (dragging: boolean) => {
|
|
180
|
+
const element = elementRef.current;
|
|
181
|
+
if (!element) return;
|
|
182
|
+
element.style.cursor = disabled ? 'default' : dragging ? 'grabbing' : 'grab';
|
|
183
|
+
// Follow the finger 1:1 while dragging; animate the snap on release.
|
|
184
|
+
element.style.transition = dragging ? 'none' : SNAP_TRANSITION;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Keep latest closures available to the mount-only effect below.
|
|
188
|
+
const clampRef = useRef(clampToLimits);
|
|
189
|
+
clampRef.current = clampToLimits;
|
|
190
|
+
const initRef = useRef(resolveInitialPosition);
|
|
191
|
+
initRef.current = resolveInitialPosition;
|
|
192
|
+
|
|
193
|
+
useLayoutEffect(() => {
|
|
194
|
+
const element = elementRef.current;
|
|
195
|
+
if (!element) return undefined;
|
|
196
|
+
|
|
197
|
+
const measure = () => {
|
|
198
|
+
const rect = element.getBoundingClientRect();
|
|
199
|
+
// A hidden widget (`display: none` while `show` is false) measures
|
|
200
|
+
// 0x0. Storing that would size every limit for a zero-width box and
|
|
201
|
+
// clamp the coordinates to the wrong corner, so keep the last real size.
|
|
202
|
+
if (rect.width === 0 && rect.height === 0) return;
|
|
203
|
+
sizeRef.current = { width: rect.width, height: rect.height };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Leave the coordinates alone while hidden: the viewport is usually
|
|
207
|
+
// mid-resize (soft keyboard opening in a webview) and clamping against it
|
|
208
|
+
// would move the widget for the next time it's shown.
|
|
209
|
+
const reclamp = () => {
|
|
210
|
+
if (!propsRef.current.show) return;
|
|
211
|
+
setPosition((prev) => clampRef.current(prev));
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
measure();
|
|
215
|
+
|
|
216
|
+
if (!isInitializedRef.current) {
|
|
217
|
+
isInitializedRef.current = true;
|
|
218
|
+
setPosition(initRef.current());
|
|
219
|
+
setIsReady(true);
|
|
220
|
+
} else {
|
|
221
|
+
reclamp();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
225
|
+
measure();
|
|
226
|
+
reclamp();
|
|
227
|
+
});
|
|
228
|
+
resizeObserver?.observe(element);
|
|
229
|
+
|
|
230
|
+
viewportWidthRef.current = window?.innerWidth ?? 360;
|
|
231
|
+
|
|
232
|
+
const onWindowResize = () => {
|
|
233
|
+
if (window.innerWidth === viewportWidthRef.current) return;
|
|
234
|
+
viewportWidthRef.current = window?.innerWidth ?? 360;
|
|
235
|
+
measure();
|
|
236
|
+
reclamp();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
window.addEventListener('resize', onWindowResize);
|
|
240
|
+
|
|
241
|
+
return () => {
|
|
242
|
+
resizeObserver?.disconnect();
|
|
243
|
+
window.removeEventListener('resize', onWindowResize);
|
|
244
|
+
};
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
// Once the resting position is painted, enable the transition on the next
|
|
248
|
+
// frame so future moves animate but the initial placement does not.
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!isReady || canAnimate) return undefined;
|
|
251
|
+
const id = requestAnimationFrame(() => setCanAnimate(true));
|
|
252
|
+
return () => cancelAnimationFrame(id);
|
|
253
|
+
}, [isReady, canAnimate]);
|
|
254
|
+
|
|
255
|
+
const handlers = useSwipeable({
|
|
256
|
+
onSwipeStart: () => {
|
|
257
|
+
if (disabled) return;
|
|
258
|
+
dragStartRef.current = positionRef.current;
|
|
259
|
+
applyDragState(true);
|
|
260
|
+
},
|
|
261
|
+
onSwiping: (eventData) => {
|
|
262
|
+
if (disabled) return;
|
|
263
|
+
const start = dragStartRef.current;
|
|
264
|
+
const next = clampToLimits({
|
|
265
|
+
x: start.x + eventData.deltaX,
|
|
266
|
+
y: start.y + eventData.deltaY,
|
|
267
|
+
});
|
|
268
|
+
positionRef.current = next;
|
|
269
|
+
applyTransform(next);
|
|
270
|
+
},
|
|
271
|
+
onSwiped: () => {
|
|
272
|
+
if (disabled) return;
|
|
273
|
+
applyDragState(false);
|
|
274
|
+
const resting = snapToNearestEdge(positionRef.current);
|
|
275
|
+
positionRef.current = resting;
|
|
276
|
+
setPosition(resting);
|
|
277
|
+
},
|
|
278
|
+
onTap: () => {
|
|
279
|
+
onTap?.();
|
|
280
|
+
},
|
|
281
|
+
trackMouse: true,
|
|
282
|
+
trackTouch: true,
|
|
283
|
+
preventScrollOnSwipe: true,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const bind: IUseDraggableResult['bind'] = {
|
|
287
|
+
...handlers,
|
|
288
|
+
ref: (element: HTMLElement | null) => {
|
|
289
|
+
handlers.ref(element);
|
|
290
|
+
elementRef.current = element;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return { bind, position, isReady, canAnimate };
|
|
295
|
+
};
|
package/src/index.ts
ADDED
package/src/style.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CSSProperties } from 'react';
|
|
2
|
+
import { Theme } from '@cleartrip/ct-design-theme';
|
|
3
|
+
|
|
4
|
+
export const getFloatingWidgetStyle = (theme: Theme): Record<string, CSSProperties> => {
|
|
5
|
+
return {
|
|
6
|
+
root: {
|
|
7
|
+
position: 'fixed',
|
|
8
|
+
top: 0,
|
|
9
|
+
left: 0,
|
|
10
|
+
willChange: 'transform',
|
|
11
|
+
touchAction: 'none',
|
|
12
|
+
userSelect: 'none',
|
|
13
|
+
WebkitUserSelect: 'none',
|
|
14
|
+
WebkitTapHighlightColor: 'transparent',
|
|
15
|
+
zIndex: 100,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
};
|
package/src/type.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ReactNode, RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A rectangular region expressed in viewport (fixed) coordinates
|
|
5
|
+
*/
|
|
6
|
+
export interface IBounds {
|
|
7
|
+
top: number;
|
|
8
|
+
left: number;
|
|
9
|
+
right: number;
|
|
10
|
+
bottom: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The "viewbox" the consumer can constrain the floating widget to.
|
|
15
|
+
* - `"viewport"` (default): the full browser window.
|
|
16
|
+
* - `IBounds`: an explicit rectangle in viewport coordinates.
|
|
17
|
+
* - a ref to an element: the element's live bounding box is used as the box.
|
|
18
|
+
*/
|
|
19
|
+
export type TBoundsInput = 'viewport' | IBounds | RefObject<HTMLElement | null>;
|
|
20
|
+
|
|
21
|
+
export interface IPosition {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export enum InitialCorner {
|
|
27
|
+
TOP_LEFT = 'top-left',
|
|
28
|
+
TOP_RIGHT = 'top-right',
|
|
29
|
+
BOTTOM_LEFT = 'bottom-left',
|
|
30
|
+
BOTTOM_RIGHT = 'bottom-right',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type TInitialCorner = `${InitialCorner}`;
|
|
34
|
+
|
|
35
|
+
export interface IFloatingWrapperProps {
|
|
36
|
+
/** The widget that should be made floating / draggable. */
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
/**
|
|
39
|
+
* Region the widget is allowed to be placed within.
|
|
40
|
+
* Defaults to the full viewport.
|
|
41
|
+
*/
|
|
42
|
+
bounds?: TBoundsInput;
|
|
43
|
+
/** Gap (px) kept between the widget and the edges of `bounds`. Default `8`. */
|
|
44
|
+
edgeOffset?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Where the widget rests initially - either an absolute `{ x, y }` (relative
|
|
47
|
+
* to `bounds`'s coordinate space) or a corner keyword. Default `"bottom-right"`.
|
|
48
|
+
*/
|
|
49
|
+
initialPosition?: IPosition | TInitialCorner;
|
|
50
|
+
/**
|
|
51
|
+
* Snap to the nearest left/right edge after a drag ends, like iOS
|
|
52
|
+
* AssistiveTouch. Default `true`.
|
|
53
|
+
*/
|
|
54
|
+
snapToEdge?: boolean;
|
|
55
|
+
/** When `true` the widget cannot be dragged. Default `false`. */
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Controls the widget's visibility. When toggled back to `true` the widget
|
|
59
|
+
* reappears at its last dragged position (the component stays mounted, so
|
|
60
|
+
* its coordinates are preserved). Default `true`.
|
|
61
|
+
*/
|
|
62
|
+
show?: boolean;
|
|
63
|
+
/** Fired on tap / click (a touch that stayed under the drag threshold). */
|
|
64
|
+
onTap?: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IUseDraggableResult {
|
|
68
|
+
/** Props to spread on the floating element (ref + pointer handlers). */
|
|
69
|
+
bind: {
|
|
70
|
+
ref: (element: HTMLElement | null) => void;
|
|
71
|
+
onMouseDown?: (event: React.MouseEvent) => void;
|
|
72
|
+
};
|
|
73
|
+
/** Current translation applied to the element. */
|
|
74
|
+
position: IPosition;
|
|
75
|
+
/**
|
|
76
|
+
* `false` until the widget has been measured and placed at its initial
|
|
77
|
+
* position. Use it to keep the widget hidden so there's no `(0,0)` flash.
|
|
78
|
+
*/
|
|
79
|
+
isReady: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* `false` for the first painted frame and `true` afterwards. Use it to gate
|
|
82
|
+
* the CSS transition so the initial placement is instant (no slide-in from
|
|
83
|
+
* `(0,0)`), while later moves (drag snap / resize) still animate.
|
|
84
|
+
*/
|
|
85
|
+
canAnimate: boolean;
|
|
86
|
+
}
|