@sproutsocial/seeds-react-modal 1.1.1 → 2.0.1
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/.turbo/turbo-build.log +23 -23
- package/CHANGELOG.md +182 -0
- package/dist/ModalAction-BB7qJtQj.d.mts +445 -0
- package/dist/ModalAction-BB7qJtQj.d.ts +445 -0
- package/dist/esm/chunk-ETVICNHP.js +1353 -0
- package/dist/esm/chunk-ETVICNHP.js.map +1 -0
- package/dist/esm/index.js +11 -11
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/v2/index.js +12 -20
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1154 -546
- package/dist/index.js.map +1 -1
- package/dist/v2/index.d.mts +4 -11
- package/dist/v2/index.d.ts +4 -11
- package/dist/v2/index.js +1152 -545
- package/dist/v2/index.js.map +1 -1
- package/package.json +8 -7
- package/src/index.ts +11 -12
- package/src/shared/constants.ts +11 -7
- package/src/v2/Modal.tsx +169 -0
- package/src/v2/ModalTypes.ts +343 -0
- package/src/v2/ModalV2.stories.tsx +413 -128
- package/src/v2/MotionConfig.ts +185 -0
- package/src/v2/components/ModalAction.tsx +94 -0
- package/src/v2/components/ModalBody.tsx +54 -0
- package/src/v2/components/ModalCloseWrapper.tsx +35 -0
- package/src/v2/components/ModalContent.tsx +288 -11
- package/src/v2/components/ModalDescription.tsx +14 -2
- package/src/v2/components/ModalFooter.tsx +94 -13
- package/src/v2/components/ModalHeader.tsx +77 -34
- package/src/v2/components/ModalOverlay.tsx +52 -0
- package/src/v2/components/ModalRail.tsx +35 -99
- package/src/v2/components/index.ts +11 -7
- package/src/v2/index.ts +13 -16
- package/dist/ModalRail-5PeilhW7.d.mts +0 -186
- package/dist/ModalRail-5PeilhW7.d.ts +0 -186
- package/dist/esm/chunk-4ITF4DBY.js +0 -717
- package/dist/esm/chunk-4ITF4DBY.js.map +0 -1
- package/src/v2/ModalV2.tsx +0 -388
- package/src/v2/ModalV2Styles.tsx +0 -180
- package/src/v2/ModalV2Types.ts +0 -154
- package/src/v2/components/ModalClose.tsx +0 -29
- package/src/v2/components/ModalCloseButton.tsx +0 -100
- package/src/v2/components/ModalTrigger.tsx +0 -39
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { Variants } from "motion/react";
|
|
2
|
+
import { MOBILE_BREAKPOINT } from "../shared/constants";
|
|
3
|
+
|
|
4
|
+
const DURATION_MOBILE: number = 0.6;
|
|
5
|
+
const DURATION_DESKTOP: number = 0.3;
|
|
6
|
+
|
|
7
|
+
const desktopTransition = {
|
|
8
|
+
duration: DURATION_DESKTOP,
|
|
9
|
+
ease: "easeInOut" as const,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const mobileTransition = {
|
|
13
|
+
duration: DURATION_MOBILE,
|
|
14
|
+
ease: "easeInOut" as const,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Animation variants for desktop modal (content and overlay).
|
|
19
|
+
* Content: Fade in with subtle scale effect for a polished entrance.
|
|
20
|
+
* Overlay: Simple fade to match content timing.
|
|
21
|
+
* IMPORTANT: Content includes translate(-50%, -50%) to center the modal since
|
|
22
|
+
* CSS transform is removed to prevent conflicts with Motion.
|
|
23
|
+
*/
|
|
24
|
+
export const desktopModalVariants: Variants = {
|
|
25
|
+
initial: {
|
|
26
|
+
opacity: 0,
|
|
27
|
+
scale: 0.95,
|
|
28
|
+
x: "-50%",
|
|
29
|
+
y: "-50%",
|
|
30
|
+
transition: desktopTransition,
|
|
31
|
+
},
|
|
32
|
+
animate: {
|
|
33
|
+
opacity: 1,
|
|
34
|
+
scale: 1,
|
|
35
|
+
x: "-50%",
|
|
36
|
+
y: "-50%",
|
|
37
|
+
transition: desktopTransition,
|
|
38
|
+
},
|
|
39
|
+
exit: {
|
|
40
|
+
opacity: 0,
|
|
41
|
+
scale: 0.95,
|
|
42
|
+
x: "-50%",
|
|
43
|
+
y: "-50%",
|
|
44
|
+
transition: desktopTransition,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Animation variants for desktop overlay.
|
|
50
|
+
* Matches desktop modal transition timing.
|
|
51
|
+
*/
|
|
52
|
+
export const desktopOverlayVariants: Variants = {
|
|
53
|
+
initial: {
|
|
54
|
+
opacity: 0,
|
|
55
|
+
transition: desktopTransition,
|
|
56
|
+
},
|
|
57
|
+
animate: {
|
|
58
|
+
opacity: 1,
|
|
59
|
+
transition: desktopTransition,
|
|
60
|
+
},
|
|
61
|
+
exit: {
|
|
62
|
+
opacity: 0,
|
|
63
|
+
transition: desktopTransition,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Animation variants for mobile drawer (content).
|
|
69
|
+
* Slides up from bottom with fade for a native-feeling drawer interaction.
|
|
70
|
+
* Includes horizontal centering (translateX(-50%)) for proper positioning.
|
|
71
|
+
*/
|
|
72
|
+
export const mobileDrawerVariants: Variants = {
|
|
73
|
+
initial: {
|
|
74
|
+
opacity: 0,
|
|
75
|
+
x: "-50%",
|
|
76
|
+
y: "100%",
|
|
77
|
+
transition: mobileTransition,
|
|
78
|
+
},
|
|
79
|
+
animate: {
|
|
80
|
+
opacity: 1,
|
|
81
|
+
x: "-50%",
|
|
82
|
+
y: 0,
|
|
83
|
+
transition: mobileTransition,
|
|
84
|
+
},
|
|
85
|
+
exit: {
|
|
86
|
+
opacity: 0,
|
|
87
|
+
x: "-50%",
|
|
88
|
+
y: "100%",
|
|
89
|
+
transition: mobileTransition,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Animation variants for mobile overlay.
|
|
95
|
+
* Matches mobile drawer transition timing.
|
|
96
|
+
*/
|
|
97
|
+
export const mobileOverlayVariants: Variants = {
|
|
98
|
+
initial: {
|
|
99
|
+
opacity: 0,
|
|
100
|
+
transition: mobileTransition,
|
|
101
|
+
},
|
|
102
|
+
animate: {
|
|
103
|
+
opacity: 1,
|
|
104
|
+
transition: mobileTransition,
|
|
105
|
+
},
|
|
106
|
+
exit: {
|
|
107
|
+
opacity: 0,
|
|
108
|
+
transition: mobileTransition,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Animation variants for draggable modals.
|
|
114
|
+
* Only animates opacity, not transforms, since dragging handles positioning.
|
|
115
|
+
* Uses desktop timing since draggable modals are desktop-only.
|
|
116
|
+
* NOTE: Centering transforms are handled in StyledContent to work with drag positioning.
|
|
117
|
+
*/
|
|
118
|
+
export const draggableModalVariants: Variants = {
|
|
119
|
+
initial: {
|
|
120
|
+
opacity: 0,
|
|
121
|
+
transition: desktopTransition,
|
|
122
|
+
},
|
|
123
|
+
animate: {
|
|
124
|
+
opacity: 1,
|
|
125
|
+
transition: desktopTransition,
|
|
126
|
+
},
|
|
127
|
+
exit: {
|
|
128
|
+
opacity: 0,
|
|
129
|
+
transition: desktopTransition,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get the appropriate content variants based on context.
|
|
135
|
+
*/
|
|
136
|
+
export function getContentVariants(
|
|
137
|
+
isMobile: boolean,
|
|
138
|
+
isDraggable: boolean
|
|
139
|
+
): Variants {
|
|
140
|
+
if (isDraggable) {
|
|
141
|
+
return draggableModalVariants;
|
|
142
|
+
}
|
|
143
|
+
return isMobile ? mobileDrawerVariants : desktopModalVariants;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get the appropriate overlay variants based on context.
|
|
148
|
+
*/
|
|
149
|
+
export function getOverlayVariants(isMobile: boolean): Variants {
|
|
150
|
+
return isMobile ? mobileOverlayVariants : desktopOverlayVariants;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Hook to detect mobile viewport based on MOBILE_BREAKPOINT (780px).
|
|
155
|
+
* Returns true if viewport is at or below the mobile breakpoint.
|
|
156
|
+
*/
|
|
157
|
+
export function useIsMobile(): boolean {
|
|
158
|
+
const [isMobile, setIsMobile] = React.useState(() => {
|
|
159
|
+
if (typeof window === "undefined") return false;
|
|
160
|
+
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`).matches;
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
React.useEffect(() => {
|
|
164
|
+
if (typeof window === "undefined") return;
|
|
165
|
+
|
|
166
|
+
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`);
|
|
167
|
+
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
168
|
+
|
|
169
|
+
// Modern browsers
|
|
170
|
+
if (mediaQuery.addEventListener) {
|
|
171
|
+
mediaQuery.addEventListener("change", handler);
|
|
172
|
+
return () => mediaQuery.removeEventListener("change", handler);
|
|
173
|
+
}
|
|
174
|
+
// Fallback for older browsers
|
|
175
|
+
else if (mediaQuery.addListener) {
|
|
176
|
+
mediaQuery.addListener(handler);
|
|
177
|
+
return () => mediaQuery.removeListener(handler);
|
|
178
|
+
}
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
return isMobile;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Need to import React for the hook
|
|
185
|
+
import * as React from "react";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
|
+
import styled from "styled-components";
|
|
4
|
+
import Icon from "@sproutsocial/seeds-react-icon";
|
|
5
|
+
import { focusRing } from "@sproutsocial/seeds-react-mixins";
|
|
6
|
+
import type { TypeModalActionProps } from "../ModalTypes";
|
|
7
|
+
import { RAIL_BUTTON_SIZE } from "../../shared/constants";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Action button component for the modal's floating rail.
|
|
11
|
+
*
|
|
12
|
+
* These buttons appear in the vertical rail next to the modal (horizontal on mobile)
|
|
13
|
+
* and provide quick access to common actions. Supports two action types:
|
|
14
|
+
* - "close": Automatically closes the modal when clicked
|
|
15
|
+
* - "button" (default): Custom action with your own onClick handler
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // Close button (built-in functionality)
|
|
19
|
+
* <ModalAction
|
|
20
|
+
* actionType="close"
|
|
21
|
+
* aria-label="Close"
|
|
22
|
+
* iconName="x-outline"
|
|
23
|
+
* />
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* // Custom action button
|
|
27
|
+
* <ModalAction
|
|
28
|
+
* aria-label="Expand to fullscreen"
|
|
29
|
+
* iconName="arrows-pointing-out"
|
|
30
|
+
* onClick={() => console.log('expand')}
|
|
31
|
+
* />
|
|
32
|
+
*/
|
|
33
|
+
const RailButton = styled.button`
|
|
34
|
+
width: ${RAIL_BUTTON_SIZE}px;
|
|
35
|
+
height: ${(props) => props.theme.space[500]};
|
|
36
|
+
display: inline-grid;
|
|
37
|
+
place-items: center;
|
|
38
|
+
border-radius: ${(props) => props.theme.radii.outer};
|
|
39
|
+
border: none;
|
|
40
|
+
background: ${(props) => props.theme.colors.button.overlay.background.base};
|
|
41
|
+
color: ${(props) => props.theme.colors.button.overlay.text.base};
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
outline: none;
|
|
44
|
+
transition: all ${(props) => props.theme.duration.fast}
|
|
45
|
+
${(props) => props.theme.easing.ease_inout};
|
|
46
|
+
|
|
47
|
+
&:hover {
|
|
48
|
+
background: ${(props) =>
|
|
49
|
+
props.theme.colors.button.overlay.background.hover};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&:hover,
|
|
53
|
+
&:active {
|
|
54
|
+
transform: none;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
&:focus-visible {
|
|
58
|
+
${focusRing}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
&:disabled {
|
|
62
|
+
opacity: 0.5;
|
|
63
|
+
cursor: not-allowed;
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
export const ModalAction: React.FC<TypeModalActionProps> = ({
|
|
68
|
+
"aria-label": ariaLabel,
|
|
69
|
+
iconName,
|
|
70
|
+
disabled,
|
|
71
|
+
actionType,
|
|
72
|
+
onClick,
|
|
73
|
+
...rest
|
|
74
|
+
}) => {
|
|
75
|
+
const button = (
|
|
76
|
+
<RailButton
|
|
77
|
+
aria-label={ariaLabel}
|
|
78
|
+
title={ariaLabel}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
onClick={onClick}
|
|
81
|
+
{...rest}
|
|
82
|
+
>
|
|
83
|
+
{iconName && <Icon name={iconName} size="small" aria-label={ariaLabel} />}
|
|
84
|
+
</RailButton>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (actionType === "close") {
|
|
88
|
+
return <Dialog.Close asChild>{button}</Dialog.Close>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return button;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
ModalAction.displayName = "ModalAction";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import styled from "styled-components";
|
|
3
|
+
import Box from "@sproutsocial/seeds-react-box";
|
|
4
|
+
import {
|
|
5
|
+
COMMON,
|
|
6
|
+
FLEXBOX,
|
|
7
|
+
BORDER,
|
|
8
|
+
LAYOUT,
|
|
9
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
10
|
+
import type { TypeModalBodyProps } from "../ModalTypes";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Modal body component for the main content area.
|
|
14
|
+
*
|
|
15
|
+
* This component provides the scrollable content area of the modal with proper spacing
|
|
16
|
+
* and overflow handling. It automatically takes up available space between the header
|
|
17
|
+
* and footer, with vertical scrolling enabled when content exceeds available height.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <ModalBody>
|
|
21
|
+
* <Text>Your modal content goes here.</Text>
|
|
22
|
+
* <FormField label="Name" />
|
|
23
|
+
* </ModalBody>
|
|
24
|
+
*/
|
|
25
|
+
const StyledModalBody = styled(Box)`
|
|
26
|
+
font-family: ${(props) => props.theme.fontFamily};
|
|
27
|
+
overflow-y: auto;
|
|
28
|
+
flex: 1 1 auto;
|
|
29
|
+
padding: ${(props) => `0 ${props.theme.space[400]}`};
|
|
30
|
+
${(props) => props.theme.typography[300]}
|
|
31
|
+
color: ${(props) => props.theme.colors.text.body};
|
|
32
|
+
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
|
|
33
|
+
flex-basis: 100%;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
${COMMON}
|
|
37
|
+
${FLEXBOX}
|
|
38
|
+
${BORDER}
|
|
39
|
+
${LAYOUT}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
StyledModalBody.displayName = "ModalBody";
|
|
43
|
+
|
|
44
|
+
export const ModalBody = React.forwardRef<HTMLDivElement, TypeModalBodyProps>(
|
|
45
|
+
({ children, ...rest }, ref) => {
|
|
46
|
+
return (
|
|
47
|
+
<StyledModalBody data-qa-modal-body ref={ref} {...rest}>
|
|
48
|
+
{children}
|
|
49
|
+
</StyledModalBody>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
ModalBody.displayName = "ModalBody";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for ModalCloseWrapper component.
|
|
6
|
+
*/
|
|
7
|
+
export interface ModalCloseWrapperProps {
|
|
8
|
+
/** The element to wrap with close functionality */
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
/** Optional click handler called before closing the modal */
|
|
11
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
12
|
+
/** Whether to merge props into the child element (default: true) */
|
|
13
|
+
asChild?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A wrapper component that closes the modal when its child is clicked.
|
|
18
|
+
* Uses asChild pattern like Radix primitives - by default asChild is true.
|
|
19
|
+
*/
|
|
20
|
+
export const ModalCloseWrapper = (props: ModalCloseWrapperProps) => {
|
|
21
|
+
const { children, onClick, asChild = true, ...rest } = props;
|
|
22
|
+
|
|
23
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
24
|
+
onClick?.(e);
|
|
25
|
+
// Dialog.Close automatically handles closing
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Dialog.Close asChild={asChild} onClick={handleClick} {...rest}>
|
|
30
|
+
{children}
|
|
31
|
+
</Dialog.Close>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
ModalCloseWrapper.displayName = "ModalCloseWrapper";
|
|
@@ -1,16 +1,293 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
2
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import styled from "styled-components";
|
|
5
|
+
import {
|
|
6
|
+
BODY_PADDING,
|
|
7
|
+
DEFAULT_MODAL_WIDTH,
|
|
8
|
+
MOBILE_BREAKPOINT,
|
|
9
|
+
RAIL_EXTRA_SPACE,
|
|
10
|
+
RAIL_BUTTON_SIZE,
|
|
11
|
+
RAIL_OFFSET,
|
|
12
|
+
} from "../../shared/constants";
|
|
13
|
+
import {
|
|
14
|
+
COMMON,
|
|
15
|
+
FLEXBOX,
|
|
16
|
+
BORDER,
|
|
17
|
+
LAYOUT,
|
|
18
|
+
type TypeSystemCommonProps,
|
|
19
|
+
type TypeSystemBorderProps,
|
|
20
|
+
type TypeSystemLayoutProps,
|
|
21
|
+
type TypeSystemFlexboxProps,
|
|
22
|
+
} from "@sproutsocial/seeds-react-system-props";
|
|
23
|
+
import { getContentVariants, useIsMobile } from "../MotionConfig";
|
|
24
|
+
|
|
25
|
+
interface StyledContentProps
|
|
26
|
+
extends TypeSystemCommonProps,
|
|
27
|
+
TypeSystemFlexboxProps,
|
|
28
|
+
TypeSystemBorderProps,
|
|
29
|
+
TypeSystemLayoutProps {
|
|
30
|
+
isDragging?: boolean;
|
|
31
|
+
draggable?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Styled motion.div wrapper that handles positioning
|
|
35
|
+
const StyledMotionWrapper = styled(motion.div)`
|
|
36
|
+
position: fixed;
|
|
37
|
+
top: ${(props: { $isMobile: boolean }) => (props.$isMobile ? "auto" : "50%")};
|
|
38
|
+
left: 50%;
|
|
39
|
+
bottom: ${(props: { $isMobile: boolean }) => (props.$isMobile ? 0 : "auto")};
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
export const StyledContent = styled.div.withConfig({
|
|
43
|
+
shouldForwardProp: (prop) => !["isDragging", "draggable"].includes(prop),
|
|
44
|
+
})<StyledContentProps>`
|
|
45
|
+
display: flex;
|
|
46
|
+
flex-direction: column;
|
|
47
|
+
border-radius: ${(props) => props.theme.radii[800]};
|
|
48
|
+
box-shadow: ${(props) => props.theme.shadows.high};
|
|
49
|
+
filter: blur(0);
|
|
50
|
+
background-color: ${(props) => props.theme.colors.container.background.base};
|
|
51
|
+
color: ${(props) => props.theme.colors.text.body};
|
|
52
|
+
outline: none;
|
|
53
|
+
width: ${DEFAULT_MODAL_WIDTH};
|
|
54
|
+
max-width: ${(props) => {
|
|
55
|
+
// Account for rail space when positioned on the side (viewport > ${MOBILE_BREAKPOINT})
|
|
56
|
+
// At viewport <= ${MOBILE_BREAKPOINT}, rail is above modal, so no horizontal space needed
|
|
57
|
+
return `calc(100vw - ${BODY_PADDING} - ${RAIL_EXTRA_SPACE}px)`;
|
|
58
|
+
}};
|
|
59
|
+
max-height: calc(100vh - ${BODY_PADDING});
|
|
60
|
+
|
|
61
|
+
/* Mobile styles for viewport <= ${MOBILE_BREAKPOINT} */
|
|
62
|
+
@media (max-width: ${MOBILE_BREAKPOINT}) {
|
|
63
|
+
/* Full viewport width - edge to edge */
|
|
64
|
+
width: 100vw;
|
|
65
|
+
max-width: 100vw;
|
|
66
|
+
min-width: 100vw;
|
|
67
|
+
|
|
68
|
+
/* Height hugs content, with increased max-height to get closer to top */
|
|
69
|
+
/* Subtract space for rail + comfortable gap (44px rail + ~40px gap) */
|
|
70
|
+
height: auto;
|
|
71
|
+
max-height: calc(95vh - 84px);
|
|
72
|
+
|
|
73
|
+
/* Adjust border radius for mobile - rounded top, flat bottom to blend with device */
|
|
74
|
+
border-top-left-radius: ${(props) => props.theme.radii[800]};
|
|
75
|
+
border-top-right-radius: ${(props) => props.theme.radii[800]};
|
|
76
|
+
border-bottom-left-radius: 0;
|
|
77
|
+
border-bottom-right-radius: 0;
|
|
78
|
+
|
|
79
|
+
/* Mobile shadow - appears to cast upward (high-reverse) */
|
|
80
|
+
box-shadow: 0px -16px 32px 0px rgba(39, 51, 51, 0.24);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
|
|
84
|
+
height: calc(100vh - ${BODY_PADDING});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
${COMMON}
|
|
88
|
+
${FLEXBOX}
|
|
89
|
+
${BORDER}
|
|
90
|
+
${LAYOUT}
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
StyledContent.displayName = "ModalContent";
|
|
94
|
+
|
|
95
|
+
// Context to share drag state between modal content and header
|
|
96
|
+
interface DragContextValue {
|
|
97
|
+
position: { x: number; y: number };
|
|
98
|
+
isDragging: boolean;
|
|
99
|
+
onHeaderMouseDown: (e: React.MouseEvent) => void;
|
|
100
|
+
contentRef: React.RefObject<HTMLDivElement>;
|
|
101
|
+
draggable: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const DragContext = React.createContext<DragContextValue | null>(null);
|
|
105
|
+
|
|
106
|
+
export const useDragContext = () => {
|
|
107
|
+
const context = React.useContext(DragContext);
|
|
108
|
+
return context;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
interface ModalContentProps {
|
|
112
|
+
children: React.ReactNode;
|
|
113
|
+
label?: string;
|
|
114
|
+
dataAttributes: Record<string, string>;
|
|
115
|
+
draggable?: boolean;
|
|
116
|
+
rest: any;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Static modal content component for non-draggable modals.
|
|
121
|
+
* This is a lightweight version that doesn't include any drag logic.
|
|
122
|
+
*/
|
|
123
|
+
export const StaticModalContent: React.FC<ModalContentProps> = ({
|
|
124
|
+
children,
|
|
125
|
+
label,
|
|
126
|
+
dataAttributes,
|
|
127
|
+
rest,
|
|
128
|
+
}) => {
|
|
129
|
+
const isMobile = useIsMobile();
|
|
130
|
+
const contentVariants = getContentVariants(isMobile, false);
|
|
4
131
|
|
|
5
|
-
export const ModalContent = React.forwardRef<
|
|
6
|
-
HTMLDivElement,
|
|
7
|
-
TypeModalV2ContentProps
|
|
8
|
-
>(({ children, ...rest }, ref) => {
|
|
9
132
|
return (
|
|
10
|
-
<
|
|
11
|
-
{
|
|
12
|
-
|
|
133
|
+
<DragContext.Provider value={null}>
|
|
134
|
+
<Dialog.Content asChild aria-label={label}>
|
|
135
|
+
<StyledMotionWrapper
|
|
136
|
+
$isMobile={isMobile}
|
|
137
|
+
variants={contentVariants}
|
|
138
|
+
initial="initial"
|
|
139
|
+
animate="animate"
|
|
140
|
+
exit="exit"
|
|
141
|
+
>
|
|
142
|
+
<StyledContent draggable={false} {...dataAttributes} {...rest}>
|
|
143
|
+
{children}
|
|
144
|
+
</StyledContent>
|
|
145
|
+
</StyledMotionWrapper>
|
|
146
|
+
</Dialog.Content>
|
|
147
|
+
</DragContext.Provider>
|
|
13
148
|
);
|
|
14
|
-
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Draggable modal content component with full drag-and-drop functionality.
|
|
153
|
+
* Only rendered when draggable={true} to avoid unnecessary overhead.
|
|
154
|
+
*/
|
|
155
|
+
export const DraggableModalContent: React.FC<ModalContentProps> = ({
|
|
156
|
+
children,
|
|
157
|
+
label,
|
|
158
|
+
dataAttributes,
|
|
159
|
+
rest,
|
|
160
|
+
}) => {
|
|
161
|
+
const [position, setPosition] = React.useState({ x: 0, y: 0 });
|
|
162
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
163
|
+
const contentRef = React.useRef<HTMLDivElement>(null);
|
|
164
|
+
const isMobile = useIsMobile();
|
|
165
|
+
|
|
166
|
+
const handleHeaderMouseDown = React.useCallback((e: React.MouseEvent) => {
|
|
167
|
+
// Only allow dragging from header (not interactive elements)
|
|
168
|
+
const target = e.target as HTMLElement;
|
|
169
|
+
if (
|
|
170
|
+
target.tagName === "BUTTON" ||
|
|
171
|
+
target.tagName === "INPUT" ||
|
|
172
|
+
target.closest("button")
|
|
173
|
+
) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
setIsDragging(true);
|
|
179
|
+
|
|
180
|
+
const rect = contentRef.current?.getBoundingClientRect();
|
|
181
|
+
if (!rect) return;
|
|
15
182
|
|
|
16
|
-
|
|
183
|
+
// Calculate offset from mouse to current modal position
|
|
184
|
+
const offsetX = e.clientX - rect.left;
|
|
185
|
+
const offsetY = e.clientY - rect.top;
|
|
186
|
+
|
|
187
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
|
|
190
|
+
// Calculate new position based on mouse position minus offset
|
|
191
|
+
const newX = e.clientX - offsetX;
|
|
192
|
+
const newY = e.clientY - offsetY;
|
|
193
|
+
|
|
194
|
+
// Determine if rail is on the side (viewport > 780px) or above (viewport <= 780px)
|
|
195
|
+
const isRailOnSide = window.innerWidth > 780;
|
|
196
|
+
|
|
197
|
+
// Constrain to viewport bounds (keeping modal AND rail fully visible)
|
|
198
|
+
const modalWidth = rect.width;
|
|
199
|
+
const modalHeight = rect.height;
|
|
200
|
+
|
|
201
|
+
// Adjust boundaries to account for rail position
|
|
202
|
+
let maxX = window.innerWidth - modalWidth;
|
|
203
|
+
let minX = 0;
|
|
204
|
+
let maxY = window.innerHeight - modalHeight;
|
|
205
|
+
let minY = 0;
|
|
206
|
+
|
|
207
|
+
if (isRailOnSide) {
|
|
208
|
+
// Rail is positioned on the right side of the modal
|
|
209
|
+
// Reduce maxX to prevent rail from going off-screen
|
|
210
|
+
maxX = window.innerWidth - modalWidth - RAIL_EXTRA_SPACE;
|
|
211
|
+
} else {
|
|
212
|
+
// Rail is positioned above the modal (viewport <= 780px)
|
|
213
|
+
// Account for rail height + offset at the top
|
|
214
|
+
minY = RAIL_BUTTON_SIZE + RAIL_OFFSET;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const constrainedX = Math.max(minX, Math.min(maxX, newX));
|
|
218
|
+
const constrainedY = Math.max(minY, Math.min(maxY, newY));
|
|
219
|
+
|
|
220
|
+
// Convert to offset from center for our transform
|
|
221
|
+
const centerX = window.innerWidth / 2 - modalWidth / 2;
|
|
222
|
+
const centerY = window.innerHeight / 2 - modalHeight / 2;
|
|
223
|
+
|
|
224
|
+
setPosition({
|
|
225
|
+
x: constrainedX - centerX,
|
|
226
|
+
y: constrainedY - centerY,
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleMouseUp = () => {
|
|
231
|
+
setIsDragging(false);
|
|
232
|
+
document.removeEventListener("mousemove", handleMouseMove);
|
|
233
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
document.addEventListener("mousemove", handleMouseMove);
|
|
237
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
const dragContextValue = React.useMemo<DragContextValue>(
|
|
241
|
+
() => ({
|
|
242
|
+
position,
|
|
243
|
+
isDragging,
|
|
244
|
+
onHeaderMouseDown: handleHeaderMouseDown,
|
|
245
|
+
contentRef,
|
|
246
|
+
draggable: true,
|
|
247
|
+
}),
|
|
248
|
+
[position, isDragging, handleHeaderMouseDown]
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// Prevent modal from closing on outside interaction when draggable
|
|
252
|
+
const handleInteractOutside = React.useCallback((e: Event) => {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
// Get appropriate animation variants based on context
|
|
257
|
+
const contentVariants = getContentVariants(isMobile, true);
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<DragContext.Provider value={dragContextValue}>
|
|
261
|
+
<Dialog.Content
|
|
262
|
+
asChild
|
|
263
|
+
aria-label={label}
|
|
264
|
+
onInteractOutside={handleInteractOutside}
|
|
265
|
+
>
|
|
266
|
+
<StyledMotionWrapper
|
|
267
|
+
$isMobile={isMobile}
|
|
268
|
+
variants={contentVariants}
|
|
269
|
+
initial="initial"
|
|
270
|
+
animate="animate"
|
|
271
|
+
exit="exit"
|
|
272
|
+
style={{
|
|
273
|
+
// Apply drag offset transforms
|
|
274
|
+
// For draggable modals, variants only handle opacity, so we apply transforms here
|
|
275
|
+
// Combined with top: 50%, left: 50%, these create the centering + drag offset
|
|
276
|
+
x: `calc(-50% + ${position.x}px)`,
|
|
277
|
+
y: `calc(-50% + ${position.y}px)`,
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<StyledContent
|
|
281
|
+
ref={contentRef}
|
|
282
|
+
draggable={true}
|
|
283
|
+
isDragging={isDragging}
|
|
284
|
+
{...dataAttributes}
|
|
285
|
+
{...rest}
|
|
286
|
+
>
|
|
287
|
+
{children}
|
|
288
|
+
</StyledContent>
|
|
289
|
+
</StyledMotionWrapper>
|
|
290
|
+
</Dialog.Content>
|
|
291
|
+
</DragContext.Provider>
|
|
292
|
+
);
|
|
293
|
+
};
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
3
|
import Box from "@sproutsocial/seeds-react-box";
|
|
4
|
-
import type {
|
|
4
|
+
import type { TypeModalDescriptionProps } from "../ModalTypes";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Modal description component that wraps content with accessible Dialog.Description.
|
|
8
|
+
*
|
|
9
|
+
* This component automatically connects description text to the modal dialog via ARIA attributes,
|
|
10
|
+
* ensuring screen readers announce the description when the modal opens. Use this for additional
|
|
11
|
+
* context beyond the title that helps users understand the modal's purpose.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <ModalDescription>
|
|
15
|
+
* Deleting this item will permanently remove it from your account.
|
|
16
|
+
* </ModalDescription>
|
|
17
|
+
*/
|
|
6
18
|
export const ModalDescription = React.forwardRef<
|
|
7
19
|
HTMLDivElement,
|
|
8
|
-
|
|
20
|
+
TypeModalDescriptionProps
|
|
9
21
|
>(({ children, descriptionProps = {}, ...rest }, ref) => {
|
|
10
22
|
return (
|
|
11
23
|
<Dialog.Description asChild {...descriptionProps}>
|