@sproutsocial/seeds-react-modal 1.0.2 → 1.0.3

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 (54) hide show
  1. package/.turbo/turbo-build.log +39 -19
  2. package/CHANGELOG.md +8 -0
  3. package/dist/Modal-ki8oiGbC.d.mts +69 -0
  4. package/dist/Modal-ki8oiGbC.d.ts +69 -0
  5. package/dist/ModalRail-OQ8DZ1vH.d.mts +178 -0
  6. package/dist/ModalRail-OQ8DZ1vH.d.ts +178 -0
  7. package/dist/esm/chunk-GKQRFPCX.js +642 -0
  8. package/dist/esm/chunk-GKQRFPCX.js.map +1 -0
  9. package/dist/esm/chunk-IYDY4OPB.js +237 -0
  10. package/dist/esm/chunk-IYDY4OPB.js.map +1 -0
  11. package/dist/esm/index.js +28 -235
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/v1/index.js +9 -0
  14. package/dist/esm/v1/index.js.map +1 -0
  15. package/dist/esm/v2/index.js +39 -0
  16. package/dist/esm/v2/index.js.map +1 -0
  17. package/dist/index.d.mts +11 -66
  18. package/dist/index.d.ts +11 -66
  19. package/dist/index.js +658 -17
  20. package/dist/index.js.map +1 -1
  21. package/dist/v1/index.d.mts +11 -0
  22. package/dist/v1/index.d.ts +11 -0
  23. package/dist/v1/index.js +273 -0
  24. package/dist/v1/index.js.map +1 -0
  25. package/dist/v2/index.d.mts +26 -0
  26. package/dist/v2/index.d.ts +26 -0
  27. package/dist/v2/index.js +694 -0
  28. package/dist/v2/index.js.map +1 -0
  29. package/package.json +33 -13
  30. package/src/Modal.stories.tsx +1 -1
  31. package/src/__tests__/v1/Modal.test.tsx +134 -0
  32. package/src/__tests__/v1/Modal.typetest.tsx +209 -0
  33. package/src/index.ts +36 -3
  34. package/src/shared/constants.ts +28 -0
  35. package/src/v1/Modal.tsx +159 -0
  36. package/src/v1/ModalTypes.ts +67 -0
  37. package/src/v1/index.ts +14 -0
  38. package/src/v1/styles.tsx +141 -0
  39. package/src/v2/ModalV2.stories.tsx +282 -0
  40. package/src/v2/ModalV2.tsx +306 -0
  41. package/src/v2/ModalV2Styles.tsx +150 -0
  42. package/src/v2/ModalV2Types.ts +158 -0
  43. package/src/v2/components/ModalClose.tsx +29 -0
  44. package/src/v2/components/ModalCloseButton.tsx +100 -0
  45. package/src/v2/components/ModalContent.tsx +16 -0
  46. package/src/v2/components/ModalDescription.tsx +19 -0
  47. package/src/v2/components/ModalFooter.tsx +20 -0
  48. package/src/v2/components/ModalHeader.tsx +52 -0
  49. package/src/v2/components/ModalRail.tsx +121 -0
  50. package/src/v2/components/ModalTrigger.tsx +39 -0
  51. package/src/v2/components/index.ts +8 -0
  52. package/src/v2/index.ts +37 -0
  53. package/tsconfig.json +7 -1
  54. package/tsup.config.ts +5 -1
@@ -0,0 +1,306 @@
1
+ import * as React from "react";
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+ import { StyledOverlay, StyledContent } from "./ModalV2Styles";
4
+ import {
5
+ ModalHeader,
6
+ ModalDescription,
7
+ ModalTrigger,
8
+ ModalRail,
9
+ ModalAction,
10
+ } from "./components";
11
+ import {
12
+ DEFAULT_MODAL_WIDTH,
13
+ DEFAULT_MODAL_BG,
14
+ DEFAULT_MODAL_Z_INDEX,
15
+ MODAL_SIZE_PRESETS,
16
+ MODAL_PRIORITY_Z_INDEX,
17
+ } from "../shared/constants";
18
+ import type { TypeModalV2Props } from "./ModalV2Types";
19
+
20
+ interface DraggableModalContentProps {
21
+ children: React.ReactNode;
22
+ computedWidth: any;
23
+ bg: any;
24
+ computedZIndex: number;
25
+ label?: string;
26
+ dataAttributes: Record<string, string>;
27
+ draggable?: boolean;
28
+ rest: any;
29
+ }
30
+
31
+ const DraggableModalContent: React.FC<DraggableModalContentProps> = ({
32
+ children,
33
+ computedWidth,
34
+ bg,
35
+ computedZIndex,
36
+ label,
37
+ dataAttributes,
38
+ draggable,
39
+ rest,
40
+ }) => {
41
+ const [position, setPosition] = React.useState({ x: 0, y: 0 });
42
+ const [isDragging, setIsDragging] = React.useState(false);
43
+ const contentRef = React.useRef<HTMLDivElement>(null);
44
+
45
+ const handleMouseDown = React.useCallback(
46
+ (e: React.MouseEvent) => {
47
+ if (!draggable) return;
48
+
49
+ // Only allow dragging from certain areas (not interactive elements)
50
+ const target = e.target as HTMLElement;
51
+ if (
52
+ target.tagName === "BUTTON" ||
53
+ target.tagName === "INPUT" ||
54
+ target.closest("button")
55
+ ) {
56
+ return;
57
+ }
58
+
59
+ e.preventDefault();
60
+ setIsDragging(true);
61
+
62
+ const rect = contentRef.current?.getBoundingClientRect();
63
+ if (!rect) return;
64
+
65
+ // Calculate offset from mouse to current modal position
66
+ const offsetX = e.clientX - rect.left;
67
+ const offsetY = e.clientY - rect.top;
68
+
69
+ const handleMouseMove = (e: MouseEvent) => {
70
+ e.preventDefault();
71
+
72
+ // Calculate new position based on mouse position minus offset
73
+ const newX = e.clientX - offsetX;
74
+ const newY = e.clientY - offsetY;
75
+
76
+ // Constrain to viewport bounds (keeping modal fully visible)
77
+ const modalWidth = rect.width;
78
+ const modalHeight = rect.height;
79
+ const maxX = window.innerWidth - modalWidth;
80
+ const minX = 0;
81
+ const maxY = window.innerHeight - modalHeight;
82
+ const minY = 0;
83
+
84
+ const constrainedX = Math.max(minX, Math.min(maxX, newX));
85
+ const constrainedY = Math.max(minY, Math.min(maxY, newY));
86
+
87
+ // Convert to offset from center for our transform
88
+ const centerX = window.innerWidth / 2 - modalWidth / 2;
89
+ const centerY = window.innerHeight / 2 - modalHeight / 2;
90
+
91
+ setPosition({
92
+ x: constrainedX - centerX,
93
+ y: constrainedY - centerY,
94
+ });
95
+ };
96
+
97
+ const handleMouseUp = () => {
98
+ setIsDragging(false);
99
+ document.removeEventListener("mousemove", handleMouseMove);
100
+ document.removeEventListener("mouseup", handleMouseUp);
101
+ };
102
+
103
+ document.addEventListener("mousemove", handleMouseMove);
104
+ document.addEventListener("mouseup", handleMouseUp);
105
+ },
106
+ [draggable]
107
+ );
108
+
109
+ return (
110
+ <StyledContent
111
+ ref={contentRef}
112
+ width={computedWidth}
113
+ bg={bg}
114
+ zIndex={computedZIndex}
115
+ aria-label={label}
116
+ draggable={draggable}
117
+ isDragging={isDragging}
118
+ onMouseDown={handleMouseDown}
119
+ style={
120
+ draggable
121
+ ? {
122
+ transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`,
123
+ transition: isDragging ? "none" : undefined,
124
+ }
125
+ : undefined
126
+ }
127
+ {...dataAttributes}
128
+ {...rest}
129
+ >
130
+ {children}
131
+ </StyledContent>
132
+ );
133
+ };
134
+
135
+ /**
136
+ * The modal you want - with Radix UI Dialog
137
+ *
138
+ * Features:
139
+ * - Built with Radix UI Dialog for superior accessibility
140
+ * - Same API as the original Modal component
141
+ * - Automatic focus management and keyboard navigation
142
+ * - Portal rendering for proper z-index layering
143
+ * - Customizable styling through system props
144
+ * - Optional draggable functionality using react-dnd
145
+ */
146
+ const Modal = (props: TypeModalV2Props) => {
147
+ const {
148
+ children,
149
+ modalTrigger,
150
+ draggable = false,
151
+ open,
152
+ defaultOpen,
153
+ onOpenChange,
154
+ "aria-label": label,
155
+ title,
156
+ subtitle,
157
+ description,
158
+ size,
159
+ priority,
160
+ data = {},
161
+ bg = DEFAULT_MODAL_BG,
162
+ showOverlay = true,
163
+ actions,
164
+ railProps,
165
+ ...rest
166
+ } = props;
167
+
168
+ const handleOpenChange = React.useCallback(
169
+ (newOpen: boolean) => {
170
+ onOpenChange?.(newOpen);
171
+ },
172
+ [onOpenChange]
173
+ );
174
+
175
+ // Compute actual width
176
+ const computedWidth = React.useMemo(() => {
177
+ if (size) {
178
+ // Handle preset sizes
179
+ if (typeof size === "string" && size in MODAL_SIZE_PRESETS) {
180
+ return MODAL_SIZE_PRESETS[size as keyof typeof MODAL_SIZE_PRESETS];
181
+ }
182
+ // Handle custom size values
183
+ return size;
184
+ }
185
+ // Fall back to default width
186
+ return DEFAULT_MODAL_WIDTH;
187
+ }, [size]);
188
+
189
+ // Compute actual z-index
190
+ const computedZIndex = React.useMemo(() => {
191
+ if (priority) {
192
+ return MODAL_PRIORITY_Z_INDEX[priority];
193
+ }
194
+ return DEFAULT_MODAL_Z_INDEX;
195
+ }, [priority]);
196
+
197
+ // Create data attributes object
198
+ const dataAttributes = React.useMemo(() => {
199
+ const attrs: Record<string, string> = {};
200
+ Object.entries(data).forEach(([key, value]) => {
201
+ attrs[`data-${key}`] = String(value);
202
+ });
203
+ attrs["data-qa-modal"] = "";
204
+ // Only add open attribute if in controlled mode
205
+ if (open !== undefined) {
206
+ attrs["data-qa-modal-open"] = String(open);
207
+ }
208
+ return attrs;
209
+ }, [data, open]);
210
+
211
+ // Determine if we should auto-render the header from provided props
212
+ const shouldRenderHeader = Boolean(title || subtitle);
213
+
214
+ // Build Dialog.Root props conditionally
215
+ const dialogRootProps = React.useMemo(() => {
216
+ const props: any = {};
217
+
218
+ // If controlled (open prop provided), use it
219
+ if (open !== undefined) {
220
+ props.open = open;
221
+ }
222
+ // If uncontrolled with explicit defaultOpen, use it
223
+ else if (defaultOpen !== undefined) {
224
+ props.defaultOpen = defaultOpen;
225
+ }
226
+ // If completely uncontrolled (neither open nor defaultOpen provided), default to closed
227
+ else {
228
+ props.defaultOpen = false;
229
+ }
230
+
231
+ // Always add onOpenChange if provided
232
+ if (onOpenChange) {
233
+ props.onOpenChange = handleOpenChange;
234
+ }
235
+
236
+ return props;
237
+ }, [open, defaultOpen, handleOpenChange, onOpenChange]);
238
+
239
+ // Handle trigger - use modalTrigger prop if provided, otherwise look for ModalTrigger children (backward compatibility)
240
+ const triggers: React.ReactNode[] = [];
241
+ const content: React.ReactNode[] = [];
242
+
243
+ if (modalTrigger) {
244
+ // New prop-based approach: wrap the provided element with Dialog.Trigger
245
+ triggers.push(
246
+ <Dialog.Trigger key="modal-trigger" asChild>
247
+ {modalTrigger}
248
+ </Dialog.Trigger>
249
+ );
250
+ // All children are content
251
+ content.push(children);
252
+ } else {
253
+ // Legacy approach: separate ModalTrigger children from content children
254
+ React.Children.forEach(children, (child) => {
255
+ if (React.isValidElement(child) && child.type === ModalTrigger) {
256
+ triggers.push(child);
257
+ } else {
258
+ content.push(child);
259
+ }
260
+ });
261
+ }
262
+
263
+ return (
264
+ <Dialog.Root {...dialogRootProps}>
265
+ {/* Render triggers as direct children of Dialog.Root */}
266
+ {triggers}
267
+
268
+ <Dialog.Portal>
269
+ {showOverlay && <StyledOverlay zIndex={computedZIndex} />}
270
+ <DraggableModalContent
271
+ computedWidth={computedWidth}
272
+ bg={bg}
273
+ computedZIndex={computedZIndex}
274
+ label={label}
275
+ dataAttributes={dataAttributes}
276
+ draggable={draggable}
277
+ rest={rest}
278
+ >
279
+ {/* Floating actions rail - always show a close by default */}
280
+ <ModalRail {...railProps}>
281
+ <ModalAction
282
+ actionType="close"
283
+ aria-label="Close"
284
+ iconName="x-outline"
285
+ />
286
+ {actions?.map((action, idx) => (
287
+ <ModalAction key={idx} {...action} />
288
+ ))}
289
+ </ModalRail>
290
+ {/* Auto-render header when title or subtitle is provided */}
291
+ {shouldRenderHeader && (
292
+ <ModalHeader title={title} subtitle={subtitle} />
293
+ )}
294
+
295
+ {/* Auto-render description when provided */}
296
+ {description && <ModalDescription>{description}</ModalDescription>}
297
+
298
+ {/* Main content (everything except triggers) */}
299
+ {content}
300
+ </DraggableModalContent>
301
+ </Dialog.Portal>
302
+ </Dialog.Root>
303
+ );
304
+ };
305
+
306
+ export default Modal;
@@ -0,0 +1,150 @@
1
+ import React from "react";
2
+ import styled from "styled-components";
3
+ import { width, zIndex } from "styled-system";
4
+ import * as Dialog from "@radix-ui/react-dialog";
5
+ import { COMMON } from "@sproutsocial/seeds-react-system-props";
6
+ import Box, { type TypeContainerProps } from "@sproutsocial/seeds-react-box";
7
+ import {
8
+ BODY_PADDING,
9
+ DEFAULT_OVERLAY_Z_INDEX_OFFSET,
10
+ } from "../shared/constants";
11
+
12
+ interface StyledOverlayProps extends TypeContainerProps {
13
+ zIndex?: number;
14
+ }
15
+
16
+ export const StyledOverlay = styled(Dialog.Overlay)<StyledOverlayProps>`
17
+ position: fixed;
18
+ top: 0px;
19
+ left: 0px;
20
+ right: 0px;
21
+ bottom: 0px;
22
+ background-color: ${(props) => props.theme.colors.overlay.background.base};
23
+ opacity: 0;
24
+ will-change: opacity;
25
+ transition: opacity ${(props) => props.theme.duration.medium}
26
+ ${(props) => props.theme.easing.ease_inout};
27
+ z-index: ${(props) =>
28
+ props.zIndex ? props.zIndex + DEFAULT_OVERLAY_Z_INDEX_OFFSET : 999};
29
+
30
+ ${zIndex}
31
+
32
+ &[data-state="open"] {
33
+ opacity: 1;
34
+ }
35
+ &[data-state="closed"] {
36
+ opacity: 0;
37
+ }
38
+ `;
39
+
40
+ interface StyledContentProps extends TypeContainerProps {
41
+ zIndex?: number;
42
+ isDragging?: boolean;
43
+ draggable?: boolean;
44
+ }
45
+
46
+ export const StyledContent = styled(Dialog.Content)<StyledContentProps>`
47
+ position: fixed;
48
+ ${(props) =>
49
+ props.draggable
50
+ ? `
51
+ top: 50%;
52
+ left: 50%;
53
+ transform: translate(-50%, -50%);
54
+ `
55
+ : `
56
+ top: 50%;
57
+ left: 50%;
58
+ transform: translate(-50%, -50%);
59
+ `}
60
+ display: flex;
61
+ flex-direction: column;
62
+ border-radius: ${(props) => props.theme.radii[600]};
63
+ box-shadow: ${(props) => props.theme.shadows.medium};
64
+ filter: blur(0);
65
+ color: ${(props) => props.theme.colors.text.body};
66
+ outline: none;
67
+ max-width: calc(100vw - ${BODY_PADDING});
68
+ max-height: calc(100vh - ${BODY_PADDING});
69
+ z-index: ${(props) => props.zIndex || 1000};
70
+
71
+ /* Draggable styling */
72
+ ${(props) =>
73
+ props.draggable &&
74
+ `
75
+ cursor: ${props.isDragging ? "grabbing" : "grab"};
76
+ user-select: none;
77
+ `}
78
+
79
+ @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
80
+ height: calc(100vh - ${BODY_PADDING});
81
+ }
82
+
83
+ ${width}
84
+ ${COMMON}
85
+
86
+ /* Enhanced Radix Dialog animations */
87
+ opacity: 0;
88
+ ${(props) =>
89
+ !props.draggable
90
+ ? `
91
+ transform: translate(-50%, -50%) scale(0.95);
92
+ transition: opacity ${props.theme.duration.medium} ${props.theme.easing.ease_inout},
93
+ transform ${props.theme.duration.medium} ${props.theme.easing.ease_inout};
94
+ `
95
+ : `
96
+ transition: opacity ${props.theme.duration.medium} ${props.theme.easing.ease_inout};
97
+ `}
98
+
99
+ &[data-state="open"] {
100
+ opacity: 1;
101
+ ${(props) =>
102
+ !props.draggable ? `transform: translate(-50%, -50%) scale(1);` : ``}
103
+ }
104
+ &[data-state="closed"] {
105
+ opacity: 0;
106
+ ${(props) =>
107
+ !props.draggable ? `transform: translate(-50%, -50%) scale(0.95);` : ``}
108
+ }
109
+ `;
110
+
111
+ export const Content = styled(Box)`
112
+ font-family: ${(props) => props.theme.fontFamily};
113
+ min-height: 80px;
114
+ overflow-y: auto;
115
+ flex: 1 1 auto;
116
+ padding: 0 ${(props) => props.theme.space[300]}
117
+ ${(props) => props.theme.space[300]} ${(props) => props.theme.space[300]};
118
+ @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
119
+ flex-basis: 100%;
120
+ }
121
+ `;
122
+
123
+ export const Header = styled(Box)`
124
+ font-family: ${(props) => props.theme.fontFamily};
125
+ padding: ${(props) => props.theme.space[400]}
126
+ ${(props) => props.theme.space[300]};
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: space-between;
130
+ flex: 0 0 auto;
131
+ `;
132
+
133
+ export const Footer = styled(Box)`
134
+ flex: 0 0 auto;
135
+ font-family: ${(props) => props.theme.fontFamily};
136
+ padding: ${(props) => props.theme.space[400]}
137
+ ${(props) => props.theme.space[300]};
138
+ border-bottom-right-radius: ${(props) => props.theme.radii[500]};
139
+ border-bottom-left-radius: ${(props) => props.theme.radii[500]};
140
+ display: flex;
141
+ align-items: center;
142
+ justify-content: flex-end;
143
+ gap: ${(props) => props.theme.space[100]};
144
+ `;
145
+
146
+ StyledOverlay.displayName = "ModalOverlay";
147
+ StyledContent.displayName = "ModalContent";
148
+ Content.displayName = "ModalContent";
149
+ Header.displayName = "ModalHeader";
150
+ Footer.displayName = "ModalFooter";
@@ -0,0 +1,158 @@
1
+ import * as React from "react";
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+ import type {
4
+ TypeBoxProps,
5
+ TypeContainerProps,
6
+ } from "@sproutsocial/seeds-react-box";
7
+ import type { TypeButtonProps } from "@sproutsocial/seeds-react-button";
8
+ import type { TypeIconName } from "@sproutsocial/seeds-react-icon";
9
+
10
+ export interface TypeModalV2TriggerProps extends TypeButtonProps {
11
+ /** The content to display inside the trigger button */
12
+ children: React.ReactNode;
13
+ /** Custom click handler - will be called before the modal opens */
14
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
15
+ /** Use asChild to render a custom trigger element */
16
+ asChild?: boolean;
17
+ }
18
+
19
+ export interface TypeModalV2CloseButtonProps
20
+ extends Omit<TypeButtonProps, "children" | "size"> {
21
+ children?: React.ReactNode;
22
+ /** Custom click handler - will be called before the modal closes */
23
+ onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
24
+ /** Use asChild to render a custom close element */
25
+ asChild?: boolean;
26
+ /** aria-label for modal close button */
27
+ closeButtonLabel?: string;
28
+ /** Size of the close button in pixels */
29
+ size?: number;
30
+ /** Position type - absolute (outside modal) or relative (inside modal) */
31
+ position?: "absolute" | "relative";
32
+ /** Which side to position the button on (for absolute positioning) */
33
+ side?: "right" | "left";
34
+ /** Offset from the modal edge in pixels (for absolute positioning) */
35
+ offset?: number;
36
+ }
37
+
38
+ export interface TypeModalV2HeaderProps extends TypeBoxProps {
39
+ title?: string;
40
+ subtitle?: string;
41
+
42
+ /** Passing children will override the default modal header */
43
+ children?: React.ReactNode;
44
+
45
+ /** If you're rendering a custom header, you can use this prop to add a bottom border */
46
+ bordered?: boolean;
47
+
48
+ /** Additional props for the Dialog.Title when title is provided */
49
+ titleProps?: Omit<
50
+ React.ComponentPropsWithoutRef<typeof Dialog.Title>,
51
+ "asChild" | "children"
52
+ >;
53
+
54
+ /** Additional props for the Dialog.Description when subtitle is provided */
55
+ subtitleProps?: Omit<
56
+ React.ComponentPropsWithoutRef<typeof Dialog.Description>,
57
+ "asChild" | "children"
58
+ >;
59
+
60
+ /** Opt-in inline close inside header; default is floating rail */
61
+ showInlineClose?: boolean;
62
+ }
63
+
64
+ export interface TypeModalV2FooterProps extends TypeBoxProps {
65
+ bg?: string;
66
+ children: React.ReactNode;
67
+ }
68
+
69
+ export interface TypeModalV2ContentProps extends TypeBoxProps {
70
+ children?: React.ReactNode;
71
+ }
72
+
73
+ export interface TypeModalV2DescriptionProps extends TypeBoxProps {
74
+ children: React.ReactNode;
75
+ /** Additional props for the Dialog.Description */
76
+ descriptionProps?: Omit<
77
+ React.ComponentPropsWithoutRef<typeof Dialog.Description>,
78
+ "asChild" | "children"
79
+ >;
80
+ }
81
+
82
+ export interface TypeModalV2Props
83
+ extends TypeContainerProps,
84
+ Omit<React.ComponentPropsWithoutRef<"div">, keyof TypeContainerProps> {
85
+ /** Controls whether the modal is open (controlled mode) */
86
+ open?: boolean;
87
+
88
+ /** Default open state for uncontrolled mode */
89
+ defaultOpen?: boolean;
90
+
91
+ /** body content of the modal */
92
+ children: React.ReactNode;
93
+
94
+ /** Callback when open state changes */
95
+ onOpenChange?: (open: boolean) => void;
96
+
97
+ /** The element that will trigger the modal when clicked.
98
+ * Can be any React element like a button, link, or custom component. */
99
+ modalTrigger?: React.ReactElement<any>;
100
+
101
+ /** Enable draggable functionality using react-dnd */
102
+ draggable?: boolean;
103
+
104
+ /** Simplified API: Modal title (creates ModalHeader automatically) */
105
+ title?: string;
106
+
107
+ /** Simplified API: Modal subtitle (creates ModalHeader automatically) */
108
+ subtitle?: string;
109
+
110
+ /** Simplified API: Modal description (automatically wrapped in Dialog.Description) */
111
+ description?: string;
112
+
113
+ /** Modal size preset or custom width */
114
+ size?: "small" | "medium" | "large" | "full" | string | number;
115
+
116
+ /** Priority level that maps to z-index presets */
117
+ priority?: "low" | "medium" | "high";
118
+
119
+ /**
120
+ * Custom attributes to be added to the modals container
121
+ * Each key will be prepended with "data-" when rendered in the DOM
122
+ */
123
+ data?: Record<string, string | boolean | number>;
124
+
125
+ /** Whether to show the background overlay (defaults to true) */
126
+ showOverlay?: boolean;
127
+
128
+ /** Optional quick actions to render on a modal side rail */
129
+ actions?: TypeModalActionProps[];
130
+
131
+ /** Optional props to customize the side rail position/size */
132
+ railProps?: Partial<TypeModalRailProps>;
133
+ }
134
+
135
+ export type TypeModalRailProps = {
136
+ side?: "right" | "left"; // default: "right"
137
+ offset?: number; // px from card edge; default: 12
138
+ gap?: number; // space between buttons; default: 12
139
+ size?: number; // button square size; default: 44
140
+ collapseAt?: number; // px viewport width to pull rail inside; default: 640
141
+ children?: React.ReactNode;
142
+ };
143
+
144
+ type ModalActionBase = Omit<
145
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
146
+ "onClick"
147
+ > & {
148
+ /** Icon name from the Seeds icon set */
149
+ iconName?: TypeIconName;
150
+ /** Optional click handler; ignored for type "close" */
151
+ onClick?: () => void;
152
+ /** Accessible name for the action button */
153
+ "aria-label": string;
154
+ };
155
+
156
+ export type TypeModalActionProps =
157
+ | (ModalActionBase & { actionType: "close" }) // uses Dialog.Close
158
+ | (ModalActionBase & { actionType?: "button" }); // regular button action
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+
4
+ export interface ModalCloseProps {
5
+ children: React.ReactNode;
6
+ onClick?: (e: React.MouseEvent) => void;
7
+ asChild?: boolean;
8
+ }
9
+
10
+ /**
11
+ * A component that closes the modal when clicked.
12
+ * Uses asChild pattern like Radix primitives.
13
+ */
14
+ export const ModalClose = (props: ModalCloseProps) => {
15
+ const { children, onClick, asChild = false, ...rest } = props;
16
+
17
+ const handleClick = (e: React.MouseEvent) => {
18
+ onClick?.(e);
19
+ // Dialog.Close automatically handles closing
20
+ };
21
+
22
+ return (
23
+ <Dialog.Close asChild={asChild} onClick={handleClick} {...rest}>
24
+ {children}
25
+ </Dialog.Close>
26
+ );
27
+ };
28
+
29
+ ModalClose.displayName = "ModalClose";