@coinbase/cds-web 8.43.0 → 8.44.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.
@@ -1,45 +1,71 @@
1
- import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
1
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
2
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
3
+ function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
4
+ function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
5
+ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
6
+ import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
7
+ import { DISMISSAL_DRAG_THRESHOLD, DISMISSAL_VELOCITY_THRESHOLD } from '@coinbase/cds-common/animation/drawer';
2
8
  import { OverlayContentContext } from '@coinbase/cds-common/overlays/OverlayContentContext';
3
- import { m, useAnimation } from 'framer-motion';
9
+ import { domMax, LazyMotion, m as motion, useAnimate, useDragControls } from 'framer-motion';
4
10
  import { IconButton } from '../../buttons';
11
+ import { cx } from '../../cx';
12
+ import { useDimensions } from '../../hooks/useDimensions';
5
13
  import { useScrollBlocker } from '../../hooks/useScrollBlocker';
14
+ import { useTheme } from '../../hooks/useTheme';
6
15
  import { Box, HStack } from '../../layout';
7
16
  import { VStack } from '../../layout/VStack';
8
17
  import { Text } from '../../typography/Text';
9
18
  import { FocusTrap } from '../FocusTrap';
19
+ import { HandleBar } from '../handlebar/HandleBar';
10
20
  import { Overlay } from '../overlay/Overlay';
11
21
  import { Portal } from '../Portal';
12
22
  import { trayContainerId } from '../PortalProvider';
13
23
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
- // Animation constants
15
- const ANIMATIONS = {
16
- SLIDE_IN: {
17
- y: 0,
24
+ const DISMISSAL_DRAG_PERCENTAGE = 0.4;
25
+ const MotionVStack = motion(Box);
26
+
27
+ /**
28
+ * Conditionally wraps children in LazyMotion with domMax features to support dragging.
29
+ */
30
+ const DragMotionProvider = _ref => {
31
+ let {
32
+ enabled,
33
+ children
34
+ } = _ref;
35
+ if (!enabled) {
36
+ return children;
37
+ }
38
+ return /*#__PURE__*/_jsx(LazyMotion, {
39
+ features: domMax,
40
+ children: children
41
+ });
42
+ };
43
+ const trayHeaderBorderBaseCss = "trayHeaderBorderBaseCss-t552jyb";
44
+ const trayHeaderBorderVisibleCss = "trayHeaderBorderVisibleCss-tx59ve0";
45
+ const trayContainerBaseCss = "trayContainerBaseCss-t1gubbn2";
46
+ const trayContainerPinBottomCss = "trayContainerPinBottomCss-ttp17ir";
47
+ const trayContainerPinTopCss = "trayContainerPinTopCss-t1ql0a7m";
48
+ const trayContainerPinLeftCss = "trayContainerPinLeftCss-tddpnpb";
49
+ const trayContainerPinRightCss = "trayContainerPinRightCss-t17d10kx";
50
+
51
+ // Extended ref type for web implementation
52
+
53
+ const animationConfig = {
54
+ slideIn: {
18
55
  transition: {
19
56
  duration: 0.3
20
57
  }
21
58
  },
22
- SLIDE_OUT: {
23
- y: '100%',
59
+ slideOut: {
24
60
  transition: {
25
61
  duration: 0.3
26
62
  }
27
- },
28
- SNAP_BACK: {
29
- y: 0,
30
- transition: {
31
- type: 'spring',
32
- stiffness: 300,
33
- damping: 30
34
- }
35
63
  }
36
64
  };
37
-
38
- // Extended props for web-specific functionality
39
-
40
- // Extended ref type for web implementation
41
-
42
- export const Tray = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Tray(_ref, ref) {
65
+ const overlayContentContextValue = {
66
+ isDrawer: true
67
+ };
68
+ export const Tray = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Tray(_ref2, ref) {
43
69
  let {
44
70
  children,
45
71
  header,
@@ -50,146 +76,282 @@ export const Tray = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Tray(_ref
50
76
  onBlur,
51
77
  onClose,
52
78
  onCloseComplete,
53
- preventDismiss = false,
79
+ preventDismiss,
54
80
  id,
55
81
  role = 'dialog',
56
82
  accessibilityLabel = 'Tray',
57
- focusTabIndexElements = false,
83
+ accessibilityLabelledBy,
84
+ focusTabIndexElements,
58
85
  restoreFocusOnUnmount = true,
59
86
  closeAccessibilityLabel = 'Close',
60
- closeAccessibilityHint
61
- } = _ref;
87
+ closeAccessibilityHint,
88
+ styles,
89
+ classNames,
90
+ zIndex,
91
+ pin = 'bottom',
92
+ showHandleBar,
93
+ hideCloseButton
94
+ } = _ref2;
95
+ const theme = useTheme();
62
96
  const [isOpen, setIsOpen] = useState(true);
97
+ const [hasScrolledDown, setHasScrolledDown] = useState(false);
63
98
  const trayRef = useRef(null);
64
- const controls = useAnimation();
99
+ const {
100
+ observe: observeTraySize,
101
+ height: trayHeight
102
+ } = useDimensions();
103
+ const contentRef = useRef(null);
104
+ const [scope, animate] = useAnimate();
105
+ const dragControls = useDragControls();
106
+ const isSideTray = pin === 'right' || pin === 'left';
107
+ const horizontalPadding = useMemo(() => pin !== 'bottom' || showHandleBar ? {
108
+ base: 4,
109
+ phone: 3
110
+ } : 6, [showHandleBar, pin]);
65
111
  const blockScroll = useScrollBlocker();
66
-
67
- // prevent body scroll when modal is open
68
112
  useEffect(() => {
69
113
  blockScroll(isOpen);
70
- return () => {
71
- blockScroll(false);
72
- };
114
+ return () => blockScroll(false);
73
115
  }, [isOpen, blockScroll]);
74
-
75
- // Setup initial animation
76
116
  useEffect(() => {
77
- controls.start(ANIMATIONS.SLIDE_IN);
78
- }, [controls]);
79
-
80
- // Unified dismissal function
117
+ onVisibilityChange === null || onVisibilityChange === void 0 || onVisibilityChange('visible');
118
+ return () => onVisibilityChange === null || onVisibilityChange === void 0 ? void 0 : onVisibilityChange('hidden');
119
+ }, [onVisibilityChange]);
81
120
  const handleClose = useCallback(() => {
82
- // Run the animation
83
- controls.start(ANIMATIONS.SLIDE_OUT).then(() => {
84
- // Then set state after animation completes
121
+ if (!scope.current) return;
122
+ animate(scope.current, isSideTray ? {
123
+ x: pin === 'right' ? '100%' : '-100%'
124
+ } : {
125
+ y: pin === 'bottom' ? '100%' : '-100%'
126
+ }, animationConfig.slideOut.transition).then(() => {
127
+ setIsOpen(false);
128
+ onClose === null || onClose === void 0 || onClose();
129
+ onCloseComplete === null || onCloseComplete === void 0 || onCloseComplete();
130
+ });
131
+ }, [animate, scope, isSideTray, pin, onClose, onCloseComplete]);
132
+ const handleSwipeClose = useCallback(() => {
133
+ if (!scope.current) return;
134
+ animate(scope.current, {
135
+ y: '100%'
136
+ }, {
137
+ duration: 0.15,
138
+ ease: 'easeOut'
139
+ }).then(() => {
85
140
  setIsOpen(false);
141
+ onBlur === null || onBlur === void 0 || onBlur();
86
142
  onClose === null || onClose === void 0 || onClose();
87
143
  onCloseComplete === null || onCloseComplete === void 0 || onCloseComplete();
88
144
  });
89
- }, [onClose, onCloseComplete, controls]);
90
- const handleOverlayPress = useCallback(() => {
145
+ }, [animate, scope, onBlur, onClose, onCloseComplete]);
146
+ useImperativeHandle(ref, () => ({
147
+ close: handleClose
148
+ }), [handleClose]);
149
+ const handleOverlayClick = useCallback(() => {
91
150
  if (!preventDismiss) {
92
151
  onBlur === null || onBlur === void 0 || onBlur();
93
152
  handleClose();
94
153
  }
95
154
  }, [handleClose, preventDismiss, onBlur]);
155
+ const handleDragEnd = useCallback((_event, info) => {
156
+ const offsetY = info.offset.y;
157
+ const velocityY = info.velocity.y;
158
+ const dragThreshold = trayHeight ? Math.min(trayHeight * DISMISSAL_DRAG_PERCENTAGE, DISMISSAL_DRAG_THRESHOLD) : DISMISSAL_DRAG_THRESHOLD;
96
159
 
97
- // Use imperative handle for cleaner ref implementation
98
- useImperativeHandle(ref, () => ({
99
- close: handleClose
100
- }), [handleClose]);
160
+ // Close if dragged past threshold OR if flicked down with high velocity
161
+ if (offsetY >= dragThreshold || velocityY >= DISMISSAL_VELOCITY_THRESHOLD) {
162
+ handleSwipeClose();
163
+ } else {
164
+ // Snap back to closed position
165
+ animate(scope.current, {
166
+ y: 0
167
+ }, {
168
+ duration: 0.2,
169
+ ease: 'easeOut'
170
+ });
171
+ }
172
+ }, [trayHeight, handleSwipeClose, animate, scope]);
173
+ const initialAnimationValue = useMemo(() => isSideTray ? {
174
+ x: pin === 'right' ? '100%' : '-100%'
175
+ } : {
176
+ y: pin === 'bottom' ? '100%' : '-100%'
177
+ }, [isSideTray, pin]);
178
+ const animateValue = useMemo(() => isSideTray ? {
179
+ x: 0
180
+ } : {
181
+ y: 0
182
+ }, [isSideTray]);
101
183
 
102
- // Handle visibility changes
184
+ // Handle bar only shows for bottom-pinned trays (matching mobile behavior)
185
+ const shouldShowHandleBar = showHandleBar && pin === 'bottom';
186
+ const shouldShrinkPadding = pin !== 'bottom' || showHandleBar;
187
+ const shouldShowCloseButton = !preventDismiss && !(hideCloseButton !== null && hideCloseButton !== void 0 ? hideCloseButton : shouldShowHandleBar);
188
+ const shouldShowTitle = title || shouldShowCloseButton;
103
189
  useEffect(() => {
104
- onVisibilityChange === null || onVisibilityChange === void 0 || onVisibilityChange('visible');
105
- return () => {
106
- onVisibilityChange === null || onVisibilityChange === void 0 || onVisibilityChange('hidden');
190
+ const content = contentRef.current;
191
+ if (!content || !shouldShrinkPadding) return;
192
+ const handleScroll = () => {
193
+ setHasScrolledDown(content.scrollTop > 0);
107
194
  };
108
- }, [onVisibilityChange]);
109
- const overlayContentContextValue = {
110
- isDrawer: true
111
- };
195
+ content.addEventListener('scroll', handleScroll, {
196
+ passive: true
197
+ });
198
+ return () => content.removeEventListener('scroll', handleScroll);
199
+ }, [shouldShrinkPadding]);
200
+ const headerContent = useMemo(() => typeof header === 'function' ? header({
201
+ handleClose
202
+ }) : header, [header, handleClose]);
203
+ const content = useMemo(() => typeof children === 'function' ? children({
204
+ handleClose
205
+ }) : children, [children, handleClose]);
206
+ const footerContent = useMemo(() => typeof footer === 'function' ? footer({
207
+ handleClose
208
+ }) : footer, [footer, handleClose]);
209
+ const trayContainerPinCss = useMemo(() => {
210
+ switch (pin) {
211
+ case 'top':
212
+ return trayContainerPinTopCss;
213
+ case 'left':
214
+ return trayContainerPinLeftCss;
215
+ case 'right':
216
+ return trayContainerPinRightCss;
217
+ case 'bottom':
218
+ default:
219
+ return trayContainerPinBottomCss;
220
+ }
221
+ }, [pin]);
112
222
  if (!isOpen) return null;
113
223
  return /*#__PURE__*/_jsx(OverlayContentContext.Provider, {
114
224
  value: overlayContentContextValue,
115
225
  children: /*#__PURE__*/_jsx(Portal, {
116
226
  containerId: trayContainerId,
117
227
  children: /*#__PURE__*/_jsxs(Box, {
228
+ ref: trayRef,
229
+ className: classNames === null || classNames === void 0 ? void 0 : classNames.root,
118
230
  height: "100vh",
119
231
  pin: "all",
120
232
  position: "fixed",
233
+ style: styles === null || styles === void 0 ? void 0 : styles.root,
121
234
  width: "100vw",
235
+ zIndex: zIndex,
122
236
  children: [/*#__PURE__*/_jsx(Overlay, {
123
- onClick: handleOverlayPress,
237
+ className: classNames === null || classNames === void 0 ? void 0 : classNames.overlay,
238
+ onClick: handleOverlayClick,
239
+ style: styles === null || styles === void 0 ? void 0 : styles.overlay,
124
240
  testID: "tray-overlay"
125
- }), /*#__PURE__*/_jsx(FocusTrap, {
126
- focusTabIndexElements: focusTabIndexElements,
127
- onEscPress: preventDismiss ? undefined : handleClose,
128
- restoreFocusOnUnmount: restoreFocusOnUnmount,
129
- children: /*#__PURE__*/_jsx(m.div, {
130
- animate: controls,
131
- initial: {
132
- y: '100%'
133
- },
134
- style: {
135
- width: '100%',
136
- position: 'absolute',
137
- bottom: 0,
138
- left: 0,
139
- right: 0,
140
- zIndex: 1,
141
- maxHeight: verticalDrawerPercentageOfView,
142
- overflowY: 'auto'
143
- },
144
- tabIndex: 0,
145
- children: /*#__PURE__*/_jsx(VStack, {
146
- ref: trayRef,
241
+ }), /*#__PURE__*/_jsx(DragMotionProvider, {
242
+ enabled: !preventDismiss,
243
+ children: /*#__PURE__*/_jsx(FocusTrap, {
244
+ focusTabIndexElements: focusTabIndexElements,
245
+ onEscPress: preventDismiss ? undefined : handleClose,
246
+ restoreFocusOnUnmount: restoreFocusOnUnmount,
247
+ children: /*#__PURE__*/_jsx(MotionVStack, {
248
+ ref: scope,
147
249
  accessibilityLabel: accessibilityLabel,
148
- alignItems: "center",
250
+ accessibilityLabelledBy: accessibilityLabelledBy,
251
+ animate: animateValue,
149
252
  "aria-modal": "true",
150
- background: "bg",
151
- borderTopLeftRadius: 400,
152
- borderTopRightRadius: 400,
253
+ bordered: theme.activeColorScheme === 'dark',
254
+ className: cx(trayContainerBaseCss, trayContainerPinCss, classNames === null || classNames === void 0 ? void 0 : classNames.container),
153
255
  "data-testid": "tray",
154
- height: "100%",
256
+ drag: !preventDismiss ? 'y' : undefined,
257
+ dragConstraints: {
258
+ top: 0,
259
+ bottom: 0
260
+ },
261
+ dragControls: dragControls,
262
+ dragElastic: {
263
+ top: 0.5,
264
+ bottom: 0.5
265
+ },
266
+ dragListener: false,
267
+ elevation: 2,
155
268
  id: id,
156
- justifyContent: "center",
157
- minHeight: 200,
269
+ initial: initialAnimationValue,
158
270
  onClick: e => e.stopPropagation(),
271
+ onDragEnd: !preventDismiss ? handleDragEnd : undefined,
272
+ pin: pin,
159
273
  role: role,
274
+ style: _objectSpread({
275
+ maxHeight: isSideTray ? undefined : verticalDrawerPercentageOfView,
276
+ touchAction: !preventDismiss && pin === 'bottom' ? 'none' : undefined
277
+ }, styles === null || styles === void 0 ? void 0 : styles.container),
278
+ tabIndex: 0,
279
+ transition: animationConfig.slideIn.transition,
280
+ width: isSideTray ? 'min(400px, 100vw)' : undefined,
160
281
  children: /*#__PURE__*/_jsxs(VStack, {
161
- maxWidth: "70em",
162
- paddingX: 6,
282
+ ref: observeTraySize,
283
+ flexGrow: 1,
284
+ maxWidth: isSideTray ? undefined : '70em',
285
+ minHeight: 0,
163
286
  width: "100%",
164
- children: [header, /*#__PURE__*/_jsxs(HStack, {
165
- alignItems: "center",
166
- justifyContent: title ? 'space-between' : 'flex-end',
167
- paddingBottom: 1,
168
- paddingTop: 3,
169
- position: "sticky",
170
- top: 0,
171
- children: [title && (typeof title === 'string' ? /*#__PURE__*/_jsx(Text, {
172
- font: "title3",
173
- children: title
174
- }) : title), !preventDismiss && /*#__PURE__*/_jsx(IconButton, {
175
- transparent: true,
287
+ children: [(shouldShowTitle || headerContent || shouldShowHandleBar) && /*#__PURE__*/_jsxs(VStack, {
288
+ className: cx(shouldShrinkPadding && trayHeaderBorderBaseCss, shouldShrinkPadding && hasScrolledDown && trayHeaderBorderVisibleCss, classNames === null || classNames === void 0 ? void 0 : classNames.header),
289
+ flexShrink: 0,
290
+ overflow: "hidden",
291
+ paddingBottom: shouldShrinkPadding ? 0.75 : 1,
292
+ paddingTop: !shouldShrinkPadding ? 3 : shouldShowHandleBar ? 0 : isSideTray ? 4 : 2,
293
+ style: styles === null || styles === void 0 ? void 0 : styles.header,
294
+ children: [shouldShowHandleBar && (preventDismiss ? /*#__PURE__*/_jsx(HandleBar, {
295
+ classNames: {
296
+ root: classNames === null || classNames === void 0 ? void 0 : classNames.handleBar,
297
+ handle: classNames === null || classNames === void 0 ? void 0 : classNames.handleBarHandle
298
+ },
299
+ styles: {
300
+ root: styles === null || styles === void 0 ? void 0 : styles.handleBar,
301
+ handle: styles === null || styles === void 0 ? void 0 : styles.handleBarHandle
302
+ }
303
+ }) : /*#__PURE__*/_jsx(HandleBar, {
176
304
  accessibilityHint: closeAccessibilityHint,
177
305
  accessibilityLabel: closeAccessibilityLabel,
178
- name: "close",
179
- onClick: handleClose,
180
- testID: "tray-close-button"
181
- })]
306
+ classNames: {
307
+ root: classNames === null || classNames === void 0 ? void 0 : classNames.handleBar,
308
+ handle: classNames === null || classNames === void 0 ? void 0 : classNames.handleBarHandle
309
+ },
310
+ onClose: handleClose,
311
+ onPointerDown: e => {
312
+ dragControls.start(e);
313
+ },
314
+ styles: {
315
+ root: styles === null || styles === void 0 ? void 0 : styles.handleBar,
316
+ handle: _objectSpread(_objectSpread({}, styles === null || styles === void 0 ? void 0 : styles.handleBarHandle), {}, {
317
+ touchAction: 'none'
318
+ })
319
+ }
320
+ })), shouldShowTitle && /*#__PURE__*/_jsxs(HStack, {
321
+ alignItems: isSideTray ? 'flex-start' : 'center',
322
+ justifyContent: title ? 'space-between' : 'flex-end',
323
+ paddingX: horizontalPadding,
324
+ children: [title && (typeof title === 'string' ? /*#__PURE__*/_jsx(Text, {
325
+ className: classNames === null || classNames === void 0 ? void 0 : classNames.title,
326
+ font: "title3",
327
+ style: styles === null || styles === void 0 ? void 0 : styles.title,
328
+ children: title
329
+ }) : title), shouldShowCloseButton && /*#__PURE__*/_jsx(IconButton, {
330
+ transparent: true,
331
+ accessibilityHint: closeAccessibilityHint,
332
+ accessibilityLabel: closeAccessibilityLabel,
333
+ className: classNames === null || classNames === void 0 ? void 0 : classNames.closeButton,
334
+ margin: isSideTray ? -1.5 : undefined,
335
+ name: "close",
336
+ onClick: handleClose,
337
+ style: styles === null || styles === void 0 ? void 0 : styles.closeButton,
338
+ testID: "tray-close-button"
339
+ })]
340
+ }), headerContent]
182
341
  }), /*#__PURE__*/_jsx(VStack, {
342
+ ref: contentRef,
343
+ className: classNames === null || classNames === void 0 ? void 0 : classNames.content,
344
+ flexGrow: 1,
183
345
  minHeight: 0,
184
- paddingBottom: 2,
185
- paddingTop: 1,
186
- style: {
346
+ overflow: "hidden",
347
+ paddingBottom: shouldShrinkPadding ? 0 : 2,
348
+ paddingTop: shouldShrinkPadding ? 0 : 1,
349
+ paddingX: horizontalPadding,
350
+ style: _objectSpread({
187
351
  overflowY: 'auto'
188
- },
189
- children: typeof children === 'function' ? children({
190
- handleClose
191
- }) : children
192
- }), footer]
352
+ }, styles === null || styles === void 0 ? void 0 : styles.content),
353
+ children: content
354
+ }), footerContent]
193
355
  })
194
356
  })
195
357
  })
@@ -197,4 +359,5 @@ export const Tray = /*#__PURE__*/memo(/*#__PURE__*/forwardRef(function Tray(_ref
197
359
  })
198
360
  })
199
361
  });
200
- }));
362
+ }));
363
+ import "./Tray.css";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinbase/cds-web",
3
- "version": "8.43.0",
3
+ "version": "8.44.0",
4
4
  "description": "Coinbase Design System - Web",
5
5
  "repository": {
6
6
  "type": "git",
@@ -207,7 +207,7 @@
207
207
  "react-dom": "^18.3.1"
208
208
  },
209
209
  "dependencies": {
210
- "@coinbase/cds-common": "^8.43.0",
210
+ "@coinbase/cds-common": "^8.44.0",
211
211
  "@coinbase/cds-icons": "^5.11.0",
212
212
  "@coinbase/cds-illustrations": "^4.31.0",
213
213
  "@coinbase/cds-lottie-files": "^3.3.4",