diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index a5684309d..2c96d4317 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -775,6 +780,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, }, + { + key: 'TrayHandleBarInside', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayHandleBarInside.stories').default, + }, { key: 'TrayInformational', getComponent: () => diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 342a579a0..2c96d4317 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -539,7 +544,7 @@ export const routes = [ { key: 'SelectChip', getComponent: () => - require('@coinbase/cds-mobile/alpha/select-chip/__stories__/SelectChip.stories').default, + require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, }, { key: 'SelectOption', @@ -775,6 +780,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, }, + { + key: 'TrayHandleBarInside', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayHandleBarInside.stories').default, + }, { key: 'TrayInformational', getComponent: () => diff --git a/packages/mobile/src/alpha/select-chip/__stories__/SelectChip.stories.tsx b/packages/mobile/src/alpha/select-chip/__stories__/AlphaSelectChip.stories.tsx similarity index 100% rename from packages/mobile/src/alpha/select-chip/__stories__/SelectChip.stories.tsx rename to packages/mobile/src/alpha/select-chip/__stories__/AlphaSelectChip.stories.tsx diff --git a/packages/mobile/src/overlays/__stories__/TrayHandleBarInside.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayHandleBarInside.stories.tsx new file mode 100644 index 000000000..40814627e --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/TrayHandleBarInside.stories.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; + +import { ScrollableTray } from './Trays'; + +export const TrayHandleBarInside = () => { + const theme = useTheme(); + return ( + + + + + + ); +}; + +export default TrayHandleBarInside; diff --git a/packages/mobile/src/overlays/__stories__/Trays.tsx b/packages/mobile/src/overlays/__stories__/Trays.tsx index 1cf6e4bd1..d2e2700b2 100644 --- a/packages/mobile/src/overlays/__stories__/Trays.tsx +++ b/packages/mobile/src/overlays/__stories__/Trays.tsx @@ -7,13 +7,13 @@ import { Menu } from '../../controls/Menu'; import { SelectOption } from '../../controls/SelectOption'; import { Fallback, VStack } from '../../layout'; import type { DrawerRefBaseProps } from '../drawer/Drawer'; -import { Tray } from '../tray/Tray'; +import { Tray, type TrayProps } from '../tray/Tray'; export const options: string[] = prices.slice(0, 4); const lotsOfOptions: string[] = prices.slice(0, 30); -export const DefaultTray = ({ title }: { title?: React.ReactNode }) => { +export const DefaultTray = (props: Partial) => { const [isTrayVisible, setIsTrayVisible] = useState(true); const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); @@ -36,7 +36,7 @@ export const DefaultTray = ({ title }: { title?: React.ReactNode }) => { ref={trayRef} onCloseComplete={setIsTrayVisibleOff} onVisibilityChange={handleTrayVisibilityChange} - title={title} + {...props} > {options.map((option: string) => ( @@ -65,14 +65,15 @@ const TrayFallbackContent = () => { ); }; +const spacingStyles = { + paddingBottom: 200, +}; + export const ScrollableTray = ({ - title, fallbackEnabled, - verticalDrawerPercentageOfView, -}: { - title?: React.ReactNode; + ...props +}: Partial & { fallbackEnabled?: boolean; - verticalDrawerPercentageOfView?: number; }) => { const [isTrayVisible, setIsTrayVisible] = useState(true); const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); @@ -88,13 +89,6 @@ export const ScrollableTray = ({ } }, [isTrayVisible, fallbackEnabled]); - const spacingStyles = useMemo( - () => ({ - paddingBottom: 200, - }), - [], - ); - const handleOptionPress = useCallback(() => { trayRef.current?.handleClose(); }, []); @@ -122,8 +116,7 @@ export const ScrollableTray = ({ ref={trayRef} disableCapturePanGestureToDismiss onCloseComplete={setIsTrayVisibleOff} - title={title} - verticalDrawerPercentageOfView={verticalDrawerPercentageOfView} + {...props} > {isLoading ? ( diff --git a/packages/mobile/src/overlays/drawer/Drawer.tsx b/packages/mobile/src/overlays/drawer/Drawer.tsx index a78952116..529522483 100644 --- a/packages/mobile/src/overlays/drawer/Drawer.tsx +++ b/packages/mobile/src/overlays/drawer/Drawer.tsx @@ -8,7 +8,13 @@ import React, { useRef, } from 'react'; import { Animated, Modal, Platform, StyleSheet, useWindowDimensions, View } from 'react-native'; -import type { ModalProps } from 'react-native'; +import type { + ModalProps, + PressableProps, + PressableStateCallbackType, + StyleProp, + ViewStyle, +} from 'react-native'; import { drawerAnimationDefaultDuration, MAX_OVER_DRAG, @@ -44,25 +50,27 @@ export type DrawerRefBaseProps = { export type DrawerBaseProps = SharedProps & Omit & { /** Component to render as the Modal content */ - children: DrawerRenderChildren | React.ReactNode; + children?: DrawerRenderChildren | React.ReactNode; /** * Pin the modal to one side of the screen * @default bottom * */ - pin: PinningDirection; + pin?: PinningDirection; /** * Prevents a user from dismissing the drawer by pressing the overlay or swiping - * @default false */ preventDismissGestures?: boolean; /** * Prevents a user from dismissing the drawer by pressing hardware back button on Android - * @default false */ preventHardwareBackBehaviorAndroid?: boolean; + /** + * The HandleBar can be rendered inside or outside the drawer. + * @default 'outside' + */ + handleBarVariant?: 'inside' | 'outside'; /** * The HandleBar by default only is used for a bottom pinned drawer. This removes it. - * @default false * */ hideHandleBar?: boolean; /** Action that will happen when drawer is dismissed */ @@ -92,30 +100,44 @@ export type DrawerBaseProps = SharedProps & stickyFooter?: DrawerRenderChildren | React.ReactNode; }; -export type DrawerProps = DrawerBaseProps; +export type DrawerProps = DrawerBaseProps & { + styles?: { + root?: StyleProp; + overlay?: StyleProp; + container?: StyleProp; + handleBar?: PressableProps['style']; + handleBarHandle?: StyleProp; + drawer?: StyleProp; + }; +}; const overlayContentContextValue: OverlayContentContextValue = { isDrawer: true, }; +const overflowStyle = { overflow: 'hidden' as const, maxHeight: '100%' as const }; + export const Drawer = memo( forwardRef(function Drawer( { children, pin = 'bottom', onCloseComplete, - preventDismissGestures = false, - preventHardwareBackBehaviorAndroid = false, - hideHandleBar = false, + preventDismissGestures, + preventHardwareBackBehaviorAndroid, + handleBarVariant = 'outside', + hideHandleBar, disableCapturePanGestureToDismiss = false, onBlur, verticalDrawerPercentageOfView = defaultVerticalDrawerPercentageOfView, handleBarAccessibilityLabel = 'Dismiss', + style, + styles, ...props }, ref, ) { - const { activeColorScheme } = useTheme(); + const theme = useTheme(); const { width, height } = useWindowDimensions(); const isAndroid = Platform.OS === 'android'; @@ -129,7 +151,7 @@ export const Drawer = memo( const [opacityAnimation, animateOverlayIn, animateOverlayOut] = useOverlayAnimation( drawerAnimationDefaultDuration, ); - const spacingStyles = useDrawerSpacing(pin); + const spacingStyle = useDrawerSpacing(pin); const isMounted = useRef(false); const handleClose = useCallback(() => { @@ -178,8 +200,10 @@ export const Drawer = memo( verticalDrawerPercentageOfView, }); - const isPinHorizontal = pin === 'left' || pin === 'right'; - const shouldShowHandleBar = !hideHandleBar && pin === 'bottom'; + const isSideDrawer = pin === 'left' || pin === 'right'; + const showHandleBar = !hideHandleBar && pin === 'bottom'; + const showHandleBarOutside = showHandleBar && handleBarVariant === 'outside'; + const showHandleBarInside = showHandleBar && handleBarVariant === 'inside'; // leave 15% of the screenwidth as open area for menu drawer const horizontalDrawerWidth = useMemo( @@ -203,70 +227,98 @@ export const Drawer = memo( } }, [handleClose, preventDismissGestures, onBlur]); - const cardStyles = StyleSheet.create({ - spacing: { - ...spacingStyles, - }, - overflowStyles: { - overflow: 'hidden', - }, - }); - - useImperativeHandle( - ref, - () => ({ - handleClose, - }), - [handleClose], - ); + useImperativeHandle(ref, () => ({ handleClose }), [handleClose]); const content = useMemo( () => (typeof children === 'function' ? children({ handleClose }) : children), [children, handleClose], ); + const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + + const containerStyle = useMemo( + () => [drawerAnimationStyles, styles?.container], + [drawerAnimationStyles, styles?.container], + ); + + const drawerStyle = useMemo( + () => [ + spacingStyle, + showHandleBarOutside && { overflow: 'visible' as const }, + styles?.drawer, + ], + [spacingStyle, showHandleBarOutside, styles?.drawer], + ); + + const handleBar = useMemo( + () => ( + + ), + [ + handleBarAccessibilityLabel, + showHandleBarInside, + handleClose, + styles?.handleBar, + styles?.handleBarHandle, + ], + ); + return ( - {shouldShowHandleBar && ( - - )} + {showHandleBarOutside && handleBar} - {content} + {showHandleBarInside && handleBar} + {content} diff --git a/packages/mobile/src/overlays/handlebar/HandleBar.tsx b/packages/mobile/src/overlays/handlebar/HandleBar.tsx index 35151e28e..58bed42a4 100644 --- a/packages/mobile/src/overlays/handlebar/HandleBar.tsx +++ b/packages/mobile/src/overlays/handlebar/HandleBar.tsx @@ -1,26 +1,39 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; -import type { AccessibilityActionEvent, ViewProps } from 'react-native'; +import type { + AccessibilityActionEvent, + PressableProps, + PressableStateCallbackType, + StyleProp, + ViewProps, + ViewStyle, +} from 'react-native'; +import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { handleBarHeight } from '@coinbase/cds-common/tokens/drawer'; import { useTheme } from '../../hooks/useTheme'; export type HandleBarProps = ViewProps & { + /** Background color of the handlebar */ + background?: ThemeVars.Color; /** Callback fired when the handlebar is pressed via accessibility action */ onAccessibilityPress?: () => void; + styles?: { + root?: PressableProps['style']; + handle?: StyleProp; + }; }; -export const HandleBar = ({ onAccessibilityPress, ...props }: HandleBarProps) => { +export const HandleBar = ({ + onAccessibilityPress, + background = 'bgSecondary', + style, + styles, + ...props +}: HandleBarProps) => { const theme = useTheme(); - const handleBarBackgroundColor = theme.color.bgSecondary; - const handleBarStyles = { - backgroundColor: handleBarBackgroundColor, - }; - - const touchableAreaStyles = { - paddingBottom: theme.space[2], - paddingTop: theme.space[2], - }; + const paddingY = theme.space[2]; + const handleBarBackgroundColor = theme.color[background]; const handleAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { @@ -31,29 +44,44 @@ export const HandleBar = ({ onAccessibilityPress, ...props }: HandleBarProps) => [onAccessibilityPress], ); + const pressableStyle = useCallback( + (state: PressableStateCallbackType) => [ + { + alignItems: 'center' as const, + paddingBottom: paddingY, + paddingTop: paddingY, + }, + style, + typeof styles?.root === 'function' ? styles?.root(state) : styles?.root, + ], + [paddingY, style, styles], + ); + + const handleBarStyle = useMemo( + () => [ + { + width: 64, + height: handleBarHeight, + backgroundColor: handleBarBackgroundColor, + borderRadius: 4, + }, + styles?.handle, + ], + [handleBarBackgroundColor, styles?.handle], + ); + return ( - + ); }; -const styles = StyleSheet.create({ - touchableArea: { - alignItems: 'center', - }, - handleBar: { - width: 64, - height: handleBarHeight, - borderRadius: 4, - }, -}); - HandleBar.displayName = 'HandleBar'; diff --git a/packages/mobile/src/overlays/tray/Tray.tsx b/packages/mobile/src/overlays/tray/Tray.tsx index 4222bfb6f..84df7c9f5 100644 --- a/packages/mobile/src/overlays/tray/Tray.tsx +++ b/packages/mobile/src/overlays/tray/Tray.tsx @@ -10,18 +10,24 @@ import React, { } from 'react'; import { useWindowDimensions } from 'react-native'; import type { ReactNode } from 'react'; -import type { LayoutChangeEvent } from 'react-native'; +import type { LayoutChangeEvent, StyleProp, TextStyle, ViewStyle } from 'react-native'; import { MAX_OVER_DRAG } from '@coinbase/cds-common/animation/drawer'; import { verticalDrawerPercentageOfView as defaultVerticalDrawerPercentageOfView } from '@coinbase/cds-common/tokens/drawer'; import { Box, HStack, VStack } from '../../layout'; import { Text } from '../../typography/Text'; -import { Drawer, type DrawerBaseProps, type DrawerRefBaseProps } from '../drawer/Drawer'; +import { + Drawer, + type DrawerBaseProps, + type DrawerProps, + type DrawerRefBaseProps, +} from '../drawer/Drawer'; export type TrayRenderChildren = React.FC<{ handleClose: () => void }>; export type TrayBaseProps = Omit & { - children: React.ReactNode | TrayRenderChildren; + children?: React.ReactNode | TrayRenderChildren; + pin?: DrawerProps['pin']; /** * Optional callback that, if provided, will be triggered when the Tray is toggled open/ closed * If used for analytics, context ('visible' | 'hidden') can be bundled with the event info to track whether the @@ -32,7 +38,15 @@ export type TrayBaseProps = Omit & { title?: React.ReactNode; }; -export type TrayProps = TrayBaseProps; +export type TrayProps = TrayBaseProps & + Omit & { + pin?: DrawerProps['pin']; + styles?: DrawerProps['styles'] & { + content?: StyleProp; + header?: StyleProp; + title?: StyleProp; + }; + }; export const TrayContext = createContext<{ verticalDrawerPercentageOfView: number; @@ -49,12 +63,24 @@ export const Tray = memo( title, onVisibilityChange, verticalDrawerPercentageOfView = defaultVerticalDrawerPercentageOfView, + styles, + pin = 'bottom', ...props }, ref, ) { const [titleHeight, setTitleHeight] = useState(0); + const { contentStyle, headerStyle, titleStyle, drawerStyles } = useMemo(() => { + const { + content: contentStyle, + header: headerStyle, + title: titleStyle, + ...drawerStyles + } = styles ?? {}; + return { contentStyle, headerStyle, titleStyle, drawerStyles }; + }, [styles]); + const onTitleLayout = useCallback( (event: LayoutChangeEvent) => { if (!title) return; @@ -65,25 +91,28 @@ export const Tray = memo( const renderChildren: TrayRenderChildren = useCallback( ({ handleClose }) => ( - - {title && - (typeof title === 'string' ? ( - - {title} - - ) : ( - {title} - ))} + + {title && ( + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + + )} {typeof children === 'function' ? children({ handleClose }) : children} ), - [children, onTitleLayout, title], + [children, onTitleLayout, contentStyle, title, headerStyle, titleStyle], ); useEffect(() => { @@ -101,10 +130,11 @@ export const Tray = memo( return ( {renderChildren} diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index a5684309d..2c96d4317 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -775,6 +780,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, }, + { + key: 'TrayHandleBarInside', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayHandleBarInside.stories').default, + }, { key: 'TrayInformational', getComponent: () => diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts index a5684309d..2c96d4317 100644 --- a/packages/ui-mobile-visreg/src/routes.ts +++ b/packages/ui-mobile-visreg/src/routes.ts @@ -43,6 +43,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, }, + { + key: 'AlphaSelectChip', + getComponent: () => + require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, + }, { key: 'AlphaTabbedChips', getComponent: () => @@ -775,6 +780,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, }, + { + key: 'TrayHandleBarInside', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/TrayHandleBarInside.stories').default, + }, { key: 'TrayInformational', getComponent: () => diff --git a/packages/web/src/overlays/tray/Tray.tsx b/packages/web/src/overlays/tray/Tray.tsx index f275d7827..5dc59a08d 100644 --- a/packages/web/src/overlays/tray/Tray.tsx +++ b/packages/web/src/overlays/tray/Tray.tsx @@ -4,18 +4,20 @@ import React, { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from 'react'; -import type { SharedAccessibilityProps } from '@coinbase/cds-common'; +import type { PinningDirection, SharedAccessibilityProps } from '@coinbase/cds-common'; import { OverlayContentContext, type OverlayContentContextValue, } from '@coinbase/cds-common/overlays/OverlayContentContext'; -import { m, useAnimation } from 'framer-motion'; +import { m as motion, useAnimation } from 'framer-motion'; import { IconButton } from '../../buttons'; import { useScrollBlocker } from '../../hooks/useScrollBlocker'; +import { useTheme } from '../../hooks/useTheme'; import { Box, HStack } from '../../layout'; import { VStack } from '../../layout/VStack'; import { Text } from '../../typography/Text'; @@ -24,6 +26,8 @@ import { Overlay } from '../overlay/Overlay'; import { Portal } from '../Portal'; import { trayContainerId } from '../PortalProvider'; +const MotionBox = motion(Box); + export type TrayRenderChildren = React.FC<{ handleClose: () => void }>; export type TrayBaseProps = { @@ -32,6 +36,11 @@ export type TrayBaseProps = { footer?: React.ReactNode; /** HTML ID for the tray */ id?: string; + /** + * Pin the tray to one side of the screen + * @default 'bottom' + */ + pin?: PinningDirection; /** Callback fired when the overlay is pressed, or swipe to close */ onBlur?: () => void; /** Action that will happen when tray is dismissed */ @@ -44,6 +53,8 @@ export type TrayBaseProps = { * multiselect was toggled into or out of view */ onVisibilityChange?: (context: 'visible' | 'hidden') => void; + /** Hide the header of the tray */ + hideHeader?: boolean; /** Prevents a user from dismissing the tray by pressing the overlay or swiping */ preventDismiss?: boolean; /** @@ -54,7 +65,7 @@ export type TrayBaseProps = { /** Text or ReactNode for optional Tray title */ title?: React.ReactNode; /** - * Allow user of component to define maximum percentage of screen that can be taken up by the Drawer + * Allow user of component to define maximum percentage of screen that can be taken up by the Drawer when pinned to the bottom or top * @example if you want a Drawer to take up 50% of the screen, you would pass a value of `"50%"` */ verticalDrawerPercentageOfView?: string; @@ -63,7 +74,6 @@ export type TrayBaseProps = { /** * Allow any element with `tabIndex` attribute to be focusable in FocusTrap, rather than only focusing specific interactive element types like button. * This can be useful when having long content in a Modal. - * @default false */ focusTabIndexElements?: boolean; /** @@ -92,32 +102,33 @@ export type TrayBaseProps = { closeAccessibilityHint?: SharedAccessibilityProps['accessibilityHint']; } & Pick; -// Animation constants -const ANIMATIONS = { - SLIDE_IN: { - y: 0, +export type TrayProps = TrayBaseProps & { + styles?: { + root?: React.CSSProperties; + overlay?: React.CSSProperties; + // content?: React.CSSProperties; + header?: React.CSSProperties; + title?: React.CSSProperties; + footer?: React.CSSProperties; + }; +}; + +// Extended ref type for web implementation +export type TrayRefProps = { + close: () => void; +}; + +const animationConfig = { + slideIn: { transition: { duration: 0.3 }, }, - SLIDE_OUT: { - y: '100%', + slideOut: { transition: { duration: 0.3 }, }, - SNAP_BACK: { - y: 0, - transition: { - type: 'spring', - stiffness: 300, - damping: 30, - }, - }, }; -// Extended props for web-specific functionality -export type TrayProps = TrayBaseProps; - -// Extended ref type for web implementation -export type TrayRefProps = { - close: () => void; +const overlayContentContextValue: OverlayContentContextValue = { + isDrawer: true, }; export const Tray = memo( @@ -130,103 +141,126 @@ export const Tray = memo( onBlur, onClose, onCloseComplete, - preventDismiss = false, + hideHeader, + preventDismiss, id, role = 'dialog', footer, accessibilityLabel = 'Tray', - focusTabIndexElements = false, + focusTabIndexElements, restoreFocusOnUnmount = true, closeAccessibilityLabel = 'Close', closeAccessibilityHint, + styles, + zIndex, + pin = 'bottom', + ...props }, ref, ) { + const theme = useTheme(); const [isOpen, setIsOpen] = useState(true); const trayRef = useRef(null); const footerRef = useRef(null); const controls = useAnimation(); + const isSideTray = pin === 'right' || pin === 'left'; const blockScroll = useScrollBlocker(); - - // prevent body scroll when modal is open useEffect(() => { blockScroll(isOpen); - - return () => { - blockScroll(false); - }; + return () => blockScroll(false); }, [isOpen, blockScroll]); - // Setup initial animation + // Initialize animation useEffect(() => { - controls.start(ANIMATIONS.SLIDE_IN); - }, [controls]); + controls.start({ + ...animationConfig.slideIn, + ...(isSideTray ? { x: 0 } : { y: 0 }), + }); + }, [controls, isSideTray]); + + useEffect(() => { + onVisibilityChange?.('visible'); + return () => onVisibilityChange?.('hidden'); + }, [onVisibilityChange]); - // Unified dismissal function const handleClose = useCallback(() => { - // Run the animation - controls.start(ANIMATIONS.SLIDE_OUT).then(() => { - // Then set state after animation completes - setIsOpen(false); - onClose?.(); - onCloseComplete?.(); - }); - }, [onClose, onCloseComplete, controls]); + controls + .start({ + ...animationConfig.slideOut, + ...(isSideTray + ? { x: pin === 'right' ? '100%' : '-100%' } + : { y: pin === 'bottom' ? '100%' : '-100%' }), + }) + .then(() => { + setIsOpen(false); + onClose?.(); + onCloseComplete?.(); + }); + }, [controls, isSideTray, pin, onClose, onCloseComplete]); + + useImperativeHandle(ref, () => ({ close: handleClose }), [handleClose]); - const handleOverlayPress = useCallback(() => { + const handleOverlayClick = useCallback(() => { if (!preventDismiss) { onBlur?.(); handleClose(); } }, [handleClose, preventDismiss, onBlur]); - // Use imperative handle for cleaner ref implementation - useImperativeHandle( - ref, + const handleTrayClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + + const initialAnimationValue = useMemo( + () => + isSideTray + ? { x: pin === 'right' ? '100%' : '-100%' } + : { y: pin === 'bottom' ? '100%' : '-100%' }, + [isSideTray, pin], + ); + + const animatedContainerStyle = useMemo( () => ({ - close: handleClose, + position: 'absolute', + zIndex: 1, + maxHeight: isSideTray ? undefined : verticalDrawerPercentageOfView, + overflowY: 'auto', }), - [handleClose], + [isSideTray, verticalDrawerPercentageOfView], ); - // Handle visibility changes - useEffect(() => { - onVisibilityChange?.('visible'); - return () => { - onVisibilityChange?.('hidden'); - }; - }, [onVisibilityChange]); - - const overlayContentContextValue: OverlayContentContextValue = { - isDrawer: true, - }; - if (!isOpen) return null; return ( - - + + - e.stopPropagation()} + justifyContent={isSideTray ? undefined : 'center'} + minHeight={isSideTray ? undefined : 200} + onClick={handleTrayClick} role={role} + width={isSideTray ? 'min(400px, 100vw)' : '100%'} + // TO DO: Styles prop integration > - - - {title && - (typeof title === 'string' ? {title} : title)} - {!preventDismiss && ( - - )} - + + {!hideHeader && ( + + {title && + (typeof title === 'string' ? ( + + {title} + + ) : ( + title + ))} + {!preventDismiss && ( + + )} + + )} {typeof children === 'function' ? children({ handleClose }) : children} {footer && ( {footer} )} - + diff --git a/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx b/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx index 91b0e7345..06f8908a6 100644 --- a/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx +++ b/packages/web/src/overlays/tray/__stories__/Tray.stories.tsx @@ -13,6 +13,25 @@ export default { component: Tray, } as Meta; +const longContent = ( + + + This example demonstrates how the tray handles a large amount of content. The tray should + expand appropriately and enable scrolling when needed. + + {Array(20) + .fill(0) + .map((_, i) => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget aliquam + ultricies, nunc nisl aliquet nunc, quis aliquam nunc nisl eu nunc. + {i % 2 === 0 && ' Sed auctor neque eu tellus rhoncus ut eleifend nibh porttitor.'} + {i % 3 === 0 && ' Ut in nulla enim. Phasellus molestie magna non est bibendum.'} + + ))} + +); + export const Default = () => { const [showBasicTray, setShowBasicTray] = useState(false); const [showCustomTitleTray, setShowCustomTitleTray] = useState(false); @@ -21,6 +40,11 @@ export const Default = () => { const [showCloseWithRefTray, setShowCloseWithRefTray] = useState(false); const [showLongContentTray, setShowLongContentTray] = useState(false); const [showNoTitleTray, setShowNoTitleTray] = useState(false); + const [showPinnedTopTray, setShowPinnedTopTray] = useState(false); + const [showPinnedRightTray, setShowPinnedRightTray] = useState(false); + const [showPinnedLeftTray, setShowPinnedLeftTray] = useState(false); + const [showLongContentPinnedTopTray, setShowLongContentPinnedTopTray] = useState(false); + const [showLongContentPinnedRightTray, setShowLongContentPinnedRightTray] = useState(false); // Refs for controlling trays const preventDismissTrayRef = useRef(null); @@ -174,26 +198,111 @@ export const Default = () => { {showLongContentTray && ( setShowLongContentTray(false)} title="Long Content Example"> + {longContent} + + )} + + + + Pinned to Right + + {showPinnedRightTray && ( + setShowPinnedRightTray(false)} + pin="right" + title="Pinned Right Tray Example" + > + + + This is a basic tray with a simple string title. Clicking outside or pressing ESC + will close it. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget + aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nunc nisl eu nunc. + + + + )} + + + + Pinned to Left + + {showPinnedLeftTray && ( + setShowPinnedLeftTray(false)} + pin="left" + title="Pinned Left Tray Example" + > - This example demonstrates how the tray handles a large amount of content. The tray - should expand appropriately and enable scrolling when needed. - - {Array(20) - .fill(0) - .map((_, i) => ( - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl - eget aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nunc nisl eu nunc. - {i % 2 === 0 && - ' Sed auctor neque eu tellus rhoncus ut eleifend nibh porttitor.'} - {i % 3 === 0 && ' Ut in nulla enim. Phasellus molestie magna non est bibendum.'} - - ))} + This is a basic tray with a simple string title. Clicking outside or pressing ESC + will close it. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget + aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nunc nisl eu nunc. + )} + + + Pinned to Top + + {showPinnedTopTray && ( + setShowPinnedTopTray(false)} + pin="top" + title="Pinned Top Tray Example" + > + + + This is a basic tray with a simple string title. Clicking outside or pressing ESC + will close it. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod, nisl eget + aliquam ultricies, nunc nisl aliquet nunc, quis aliquam nunc nisl eu nunc. + + + + )} + + + + Tray with Long Content Pinned to Top + + {showLongContentPinnedTopTray && ( + setShowLongContentPinnedTopTray(false)} + pin="top" + title="Long Content Top Example" + > + {longContent} + + )} + + + + Tray with Long Content Pinned to Right + + {showLongContentPinnedRightTray && ( + setShowLongContentPinnedRightTray(false)} + pin="right" + title="Long Content Right Example" + > + {longContent} + + )} + ); };