@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.
Files changed (33) hide show
  1. package/dist/FloatingWrapper.d.ts +2 -3
  2. package/dist/FloatingWrapper.d.ts.map +1 -1
  3. package/dist/FloatingWrapper.native.d.ts +4 -0
  4. package/dist/FloatingWrapper.native.d.ts.map +1 -0
  5. package/dist/ct-design-floating-wrapper.browser.cjs.js +1 -1
  6. package/dist/ct-design-floating-wrapper.browser.cjs.js.map +1 -1
  7. package/dist/ct-design-floating-wrapper.browser.esm.js +1 -1
  8. package/dist/ct-design-floating-wrapper.browser.esm.js.map +1 -1
  9. package/dist/ct-design-floating-wrapper.cjs.js +99 -105
  10. package/dist/ct-design-floating-wrapper.cjs.js.map +1 -1
  11. package/dist/ct-design-floating-wrapper.esm.js +100 -106
  12. package/dist/ct-design-floating-wrapper.esm.js.map +1 -1
  13. package/dist/ct-design-floating-wrapper.umd.js +102 -137
  14. package/dist/ct-design-floating-wrapper.umd.js.map +1 -1
  15. package/dist/hooks/useDraggable.d.ts.map +1 -1
  16. package/dist/hooks/useDraggable.native.d.ts +10 -0
  17. package/dist/hooks/useDraggable.native.d.ts.map +1 -0
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.native.d.ts +4 -0
  20. package/dist/index.native.d.ts.map +1 -0
  21. package/dist/style.d.ts +2 -2
  22. package/dist/style.d.ts.map +1 -1
  23. package/dist/type.d.ts +2 -2
  24. package/dist/type.d.ts.map +1 -1
  25. package/package.json +32 -6
  26. package/src/FloatingWrapper.native.tsx +46 -0
  27. package/src/FloatingWrapper.tsx +54 -0
  28. package/src/hooks/useDraggable.native.ts +191 -0
  29. package/src/hooks/useDraggable.ts +295 -0
  30. package/src/index.native.ts +3 -0
  31. package/src/index.ts +3 -0
  32. package/src/style.ts +18 -0
  33. 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
+ };
@@ -0,0 +1,3 @@
1
+ export { default as FloatingWrapper } from './FloatingWrapper';
2
+ export { useDraggable } from './hooks/useDraggable';
3
+ export * from './type';
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default as FloatingWrapper } from './FloatingWrapper';
2
+ export { useDraggable } from './hooks/useDraggable';
3
+ export * from './type';
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
+ }