@sproutsocial/seeds-react-modal 1.1.0 → 2.0.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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +23 -23
  2. package/CHANGELOG.md +182 -0
  3. package/dist/ModalAction-BB7qJtQj.d.mts +445 -0
  4. package/dist/ModalAction-BB7qJtQj.d.ts +445 -0
  5. package/dist/esm/chunk-ETVICNHP.js +1353 -0
  6. package/dist/esm/chunk-ETVICNHP.js.map +1 -0
  7. package/dist/esm/index.js +11 -11
  8. package/dist/esm/index.js.map +1 -1
  9. package/dist/esm/v2/index.js +12 -20
  10. package/dist/index.d.mts +2 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +1154 -546
  13. package/dist/index.js.map +1 -1
  14. package/dist/v2/index.d.mts +4 -11
  15. package/dist/v2/index.d.ts +4 -11
  16. package/dist/v2/index.js +1152 -545
  17. package/dist/v2/index.js.map +1 -1
  18. package/package.json +8 -7
  19. package/src/index.ts +11 -12
  20. package/src/shared/constants.ts +11 -7
  21. package/src/v2/Modal.tsx +169 -0
  22. package/src/v2/ModalTypes.ts +343 -0
  23. package/src/v2/ModalV2.stories.tsx +413 -128
  24. package/src/v2/MotionConfig.ts +185 -0
  25. package/src/v2/components/ModalAction.tsx +94 -0
  26. package/src/v2/components/ModalBody.tsx +54 -0
  27. package/src/v2/components/ModalCloseWrapper.tsx +35 -0
  28. package/src/v2/components/ModalContent.tsx +288 -11
  29. package/src/v2/components/ModalDescription.tsx +14 -2
  30. package/src/v2/components/ModalFooter.tsx +94 -13
  31. package/src/v2/components/ModalHeader.tsx +77 -34
  32. package/src/v2/components/ModalOverlay.tsx +52 -0
  33. package/src/v2/components/ModalRail.tsx +35 -99
  34. package/src/v2/components/index.ts +11 -7
  35. package/src/v2/index.ts +13 -16
  36. package/dist/ModalRail-5PeilhW7.d.mts +0 -186
  37. package/dist/ModalRail-5PeilhW7.d.ts +0 -186
  38. package/dist/esm/chunk-4ITF4DBY.js +0 -717
  39. package/dist/esm/chunk-4ITF4DBY.js.map +0 -1
  40. package/src/v2/ModalV2.tsx +0 -388
  41. package/src/v2/ModalV2Styles.tsx +0 -180
  42. package/src/v2/ModalV2Types.ts +0 -154
  43. package/src/v2/components/ModalClose.tsx +0 -29
  44. package/src/v2/components/ModalCloseButton.tsx +0 -100
  45. package/src/v2/components/ModalTrigger.tsx +0 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-modal",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Seeds React Modal",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -36,12 +36,13 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@radix-ui/react-dialog": "^1.1.14",
39
- "@sproutsocial/seeds-react-box": "^1.1.4",
40
- "@sproutsocial/seeds-react-button": "^1.2.3",
41
- "@sproutsocial/seeds-react-icon": "^1.1.4",
39
+ "@sproutsocial/seeds-react-box": "^1.1.5",
40
+ "@sproutsocial/seeds-react-button": "^1.3.1",
41
+ "@sproutsocial/seeds-react-icon": "^1.1.5",
42
42
  "@sproutsocial/seeds-react-system-props": "^3.0.1",
43
43
  "@sproutsocial/seeds-react-text": "^1.3.2",
44
- "@sproutsocial/seeds-react-theme": "^3.1.0",
44
+ "@sproutsocial/seeds-react-theme": "^3.1.1",
45
+ "motion": "^12.6.3",
45
46
  "react-dnd": "^16.0.1",
46
47
  "react-dnd-html5-backend": "^16.0.1",
47
48
  "react-modal": "^3.16.1",
@@ -49,8 +50,8 @@
49
50
  },
50
51
  "devDependencies": {
51
52
  "@sproutsocial/eslint-config-seeds": "*",
52
- "@sproutsocial/seeds-react-form-field": "^1.0.3",
53
- "@sproutsocial/seeds-react-input": "^1.4.5",
53
+ "@sproutsocial/seeds-react-form-field": "^1.0.4",
54
+ "@sproutsocial/seeds-react-input": "^1.4.7",
54
55
  "@sproutsocial/seeds-react-testing-library": "*",
55
56
  "@sproutsocial/seeds-testing": "*",
56
57
  "@sproutsocial/seeds-tsconfig": "*",
package/src/index.ts CHANGED
@@ -12,27 +12,26 @@ export type {
12
12
  TypeModalCloseButtonProps,
13
13
  } from "./v1";
14
14
 
15
- // V2 Modal exports
15
+ // V2 Modal exports (renamed to just Modal)
16
16
  export {
17
17
  Modal as ModalV2,
18
- ModalTrigger,
19
18
  ModalDescription,
20
19
  ModalHeader,
21
20
  ModalFooter,
22
- ModalContent,
23
- ModalCloseButton,
24
- ModalClose,
21
+ ModalBody,
22
+ ModalCloseWrapper,
25
23
  ModalRail,
26
24
  ModalAction,
25
+ ModalCustomHeader,
26
+ ModalCustomFooter,
27
27
  } from "./v2";
28
28
  export type {
29
- TypeModalV2Props,
30
- TypeModalV2TriggerProps,
31
- TypeModalV2CloseButtonProps,
32
- TypeModalV2HeaderProps,
33
- TypeModalV2FooterProps,
34
- TypeModalV2ContentProps,
35
- TypeModalV2DescriptionProps,
29
+ TypeModalProps as TypeModalV2Props,
30
+ TypeModalHeaderProps as TypeModalV2HeaderProps,
31
+ TypeModalFooterProps as TypeModalV2FooterProps,
32
+ TypeModalBodyProps as TypeModalV2BodyProps,
33
+ TypeModalDescriptionProps as TypeModalV2DescriptionProps,
36
34
  TypeModalRailProps,
37
35
  TypeModalActionProps,
36
+ ModalCloseWrapperProps,
38
37
  } from "./v2";
@@ -1,6 +1,6 @@
1
1
  // Shared constants for both Modal versions
2
2
 
3
- // Default z-index values
3
+ // Default z-index values (v1 only - v2 relies on portal stacking order)
4
4
  export const DEFAULT_MODAL_Z_INDEX = 6;
5
5
  export const DEFAULT_OVERLAY_Z_INDEX_OFFSET = -1;
6
6
 
@@ -10,6 +10,8 @@ export const DEFAULT_MODAL_BG = "container.background.base";
10
10
  export const DEFAULT_CLOSE_BUTTON_LABEL = "Close dialog";
11
11
 
12
12
  // Max space allowed between the modal and the edge of the browser
13
+ // Note: 64px doesn't have a direct space token match (space[500] = 32px, space[600] = 40px)
14
+ // Keeping as hardcoded value for now as it's a specific layout constraint
13
15
  export const BODY_PADDING = "64px";
14
16
 
15
17
  // Size presets for simplified API
@@ -20,9 +22,11 @@ export const MODAL_SIZE_PRESETS = {
20
22
  full: "90vw",
21
23
  } as const;
22
24
 
23
- // Priority level z-index mappings
24
- export const MODAL_PRIORITY_Z_INDEX = {
25
- low: 100,
26
- medium: 1000,
27
- high: 2000,
28
- } as const;
25
+ // Mobile breakpoint for bottom sheet layout
26
+ export const MOBILE_BREAKPOINT = "780px";
27
+
28
+ // Rail button constants
29
+ export const RAIL_BUTTON_SIZE = 44; // px
30
+ export const RAIL_OFFSET = 12; // px from card edge
31
+ export const RAIL_GAP = 12; // px between buttons
32
+ export const RAIL_EXTRA_SPACE = RAIL_BUTTON_SIZE + RAIL_OFFSET; // 56px
@@ -0,0 +1,169 @@
1
+ import * as React from "react";
2
+ import * as Dialog from "@radix-ui/react-dialog";
3
+ import { AnimatePresence } from "motion/react";
4
+ import {
5
+ StyledOverlay,
6
+ StyledMotionOverlay,
7
+ DraggableModalContent,
8
+ StaticModalContent,
9
+ ModalHeader,
10
+ ModalDescription,
11
+ ModalRail,
12
+ ModalAction,
13
+ } from "./components";
14
+ import type { TypeModalProps } from "./ModalTypes";
15
+ import { getOverlayVariants, useIsMobile } from "./MotionConfig";
16
+
17
+ /**
18
+ * Accessible modal dialog component built on Radix UI Dialog primitives.
19
+ *
20
+ * This component provides a flexible modal implementation with comprehensive accessibility
21
+ * features, keyboard navigation, and focus management built in.
22
+ *
23
+ * Key capabilities:
24
+ * - Automatic ARIA attributes and focus trapping
25
+ * - ESC key and outside click to close
26
+ * - Simplified API with title/subtitle props for common use cases
27
+ * - Controlled and uncontrolled state modes
28
+ * - Optional draggable behavior for side-by-side interaction
29
+ * - Floating action rail for quick actions like close, expand, etc.
30
+ * - Responsive bottom sheet layout on mobile
31
+ *
32
+ * @example
33
+ * // Simple uncontrolled modal
34
+ * <Modal
35
+ * title="Delete Item"
36
+ * subtitle="This action cannot be undone"
37
+ * modalTrigger={<Button>Delete</Button>}
38
+ * >
39
+ * <ModalBody>Are you sure you want to delete this item?</ModalBody>
40
+ * <ModalFooter
41
+ * cancelButton={<Button>Cancel</Button>}
42
+ * primaryButton={<Button appearance="destructive">Delete</Button>}
43
+ * />
44
+ * </Modal>
45
+ */
46
+ const Modal = (props: TypeModalProps) => {
47
+ const {
48
+ children,
49
+ modalTrigger,
50
+ draggable = false,
51
+ open,
52
+ defaultOpen,
53
+ onOpenChange,
54
+ "aria-label": label,
55
+ title,
56
+ subtitle,
57
+ description,
58
+ data = {},
59
+ showOverlay = true,
60
+ actions,
61
+ closeButtonAriaLabel = "Close",
62
+ ...rest
63
+ } = props;
64
+
65
+ // Track open state for AnimatePresence
66
+ // This state is synced via onOpenChange regardless of controlled/uncontrolled mode
67
+ const [isOpen, setIsOpen] = React.useState(defaultOpen ?? false);
68
+
69
+ const handleOpenChange = React.useCallback(
70
+ (newOpen: boolean) => {
71
+ // Always sync state for AnimatePresence
72
+ setIsOpen(newOpen);
73
+ // Call user's callback
74
+ onOpenChange?.(newOpen);
75
+ },
76
+ [onOpenChange]
77
+ );
78
+
79
+ // Create data attributes object
80
+ const dataAttributes = React.useMemo(() => {
81
+ const attrs: Record<string, string> = {};
82
+ Object.entries(data).forEach(([key, value]) => {
83
+ attrs[`data-${key}`] = String(value);
84
+ });
85
+ attrs["data-qa-modal"] = "";
86
+ // Only add open attribute if in controlled mode
87
+ if (open !== undefined) {
88
+ attrs["data-qa-modal-open"] = String(open);
89
+ }
90
+ return attrs;
91
+ }, [data, open]);
92
+
93
+ // Determine if we should auto-render the header from provided props
94
+ const shouldRenderHeader = Boolean(title || subtitle);
95
+
96
+ // Get mobile state and appropriate overlay variants
97
+ const isMobile = useIsMobile();
98
+ const overlayVariants = getOverlayVariants(isMobile);
99
+
100
+ // Choose the appropriate content component based on draggable prop
101
+ const ModalContentComponent = draggable
102
+ ? DraggableModalContent
103
+ : StaticModalContent;
104
+
105
+ return (
106
+ <Dialog.Root
107
+ open={open}
108
+ defaultOpen={defaultOpen}
109
+ onOpenChange={handleOpenChange}
110
+ modal={!draggable}
111
+ >
112
+ {/* Render trigger button if provided */}
113
+ {modalTrigger && <Dialog.Trigger asChild>{modalTrigger}</Dialog.Trigger>}
114
+
115
+ <Dialog.Portal forceMount>
116
+ <AnimatePresence mode="wait">
117
+ {(open ?? isOpen) && (
118
+ <>
119
+ {showOverlay && (
120
+ <Dialog.Overlay asChild>
121
+ <StyledMotionOverlay
122
+ variants={overlayVariants}
123
+ initial="initial"
124
+ animate="animate"
125
+ exit="exit"
126
+ >
127
+ <StyledOverlay allowInteraction={draggable} />
128
+ </StyledMotionOverlay>
129
+ </Dialog.Overlay>
130
+ )}
131
+ <ModalContentComponent
132
+ label={label}
133
+ dataAttributes={dataAttributes}
134
+ draggable={draggable}
135
+ rest={rest}
136
+ >
137
+ {/* Floating actions rail - always show a close by default */}
138
+ <ModalRail>
139
+ <ModalAction
140
+ actionType="close"
141
+ aria-label={closeButtonAriaLabel}
142
+ iconName="x-outline"
143
+ />
144
+ {actions?.map((action, idx) => (
145
+ <ModalAction key={idx} {...action} />
146
+ ))}
147
+ </ModalRail>
148
+ {/* Auto-render header when title or subtitle is provided */}
149
+ {shouldRenderHeader && (
150
+ <ModalHeader title={title} subtitle={subtitle} />
151
+ )}
152
+
153
+ {/* Auto-render description when provided */}
154
+ {description && (
155
+ <ModalDescription>{description}</ModalDescription>
156
+ )}
157
+
158
+ {/* Main content */}
159
+ {children}
160
+ </ModalContentComponent>
161
+ </>
162
+ )}
163
+ </AnimatePresence>
164
+ </Dialog.Portal>
165
+ </Dialog.Root>
166
+ );
167
+ };
168
+
169
+ export default Modal;
@@ -0,0 +1,343 @@
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 { TypeIconName } from "@sproutsocial/seeds-react-icon";
8
+
9
+ /**
10
+ * Props for ModalHeader component.
11
+ *
12
+ * Renders the modal's header with title and optional subtitle. Supports draggable functionality
13
+ * when the modal has draggable enabled.
14
+ *
15
+ * Note: This component only supports slots (title/subtitle props). For custom headers,
16
+ * use ModalCustomHeader instead.
17
+ */
18
+ export interface TypeModalHeaderProps extends TypeBoxProps {
19
+ /** Modal title text displayed as a headline */
20
+ title?: string;
21
+ /**
22
+ * Modal subtitle text.
23
+ *
24
+ * This is automatically wrapped in `Dialog.Description` from Radix UI,
25
+ * which provides accessible description text for screen readers. The subtitle
26
+ * provides additional context about the modal and is announced alongside
27
+ * the title when the dialog opens. Radix UI automatically connects this to
28
+ * the dialog via ARIA attributes (typically `aria-describedby` on the dialog root).
29
+ */
30
+ subtitle?: string;
31
+
32
+ /** Additional props for the Dialog.Title when title is provided */
33
+ titleProps?: Omit<
34
+ React.ComponentPropsWithoutRef<typeof Dialog.Title>,
35
+ "asChild" | "children"
36
+ >;
37
+
38
+ /**
39
+ * Additional props for the Dialog.Description when subtitle is provided.
40
+ *
41
+ * Dialog.Description provides accessible description text for the modal that
42
+ * screen readers announce when the dialog opens. It complements Dialog.Title
43
+ * by providing additional context about the modal. Radix UI automatically
44
+ * connects this to the dialog via ARIA attributes.
45
+ */
46
+ subtitleProps?: Omit<
47
+ React.ComponentPropsWithoutRef<typeof Dialog.Description>,
48
+ "asChild" | "children"
49
+ >;
50
+ }
51
+
52
+ /**
53
+ * Props for ModalFooter component.
54
+ *
55
+ * Provides automatic button wrapping and layout management. At least one action
56
+ * (cancelButton, primaryButton, or leftAction) must be provided.
57
+ *
58
+ * Note: This component only supports slots (button props). For custom footers,
59
+ * use ModalCustomFooter instead.
60
+ *
61
+ * @example
62
+ * // ✅ Valid - has primary button
63
+ * <ModalFooter primaryButton={<Button>Save</Button>} />
64
+ *
65
+ * @example
66
+ * // ✅ Valid - has cancel and primary
67
+ * <ModalFooter
68
+ * cancelButton={<Button>Cancel</Button>}
69
+ * primaryButton={<Button>Save</Button>}
70
+ * />
71
+ *
72
+ * @example
73
+ * // ✅ Valid - left action only
74
+ * <ModalFooter leftAction={<Button>Delete</Button>} />
75
+ *
76
+ * @example
77
+ * // ❌ TypeScript Error - no actions provided
78
+ * <ModalFooter />
79
+ */
80
+ export type TypeModalFooterProps = TypeBoxProps &
81
+ (
82
+ | {
83
+ /** Primary action button - automatically wrapped in ModalCloseWrapper to close the modal */
84
+ primaryButton: React.ReactNode;
85
+ /** Cancel/secondary button - automatically wrapped in ModalCloseWrapper to close the modal */
86
+ cancelButton?: React.ReactNode;
87
+ /** Optional action on the far left (e.g., Delete button) - NOT automatically wrapped */
88
+ leftAction?: React.ReactNode;
89
+ }
90
+ | {
91
+ /** Primary action button - automatically wrapped in ModalCloseWrapper to close the modal */
92
+ primaryButton?: React.ReactNode;
93
+ /** Cancel/secondary button - automatically wrapped in ModalCloseWrapper to close the modal */
94
+ cancelButton: React.ReactNode;
95
+ /** Optional action on the far left (e.g., Delete button) - NOT automatically wrapped */
96
+ leftAction?: React.ReactNode;
97
+ }
98
+ | {
99
+ /** Primary action button - automatically wrapped in ModalCloseWrapper to close the modal */
100
+ primaryButton?: React.ReactNode;
101
+ /** Cancel/secondary button - automatically wrapped in ModalCloseWrapper to close the modal */
102
+ cancelButton?: React.ReactNode;
103
+ /** Optional action on the far left (e.g., Delete button) - NOT automatically wrapped */
104
+ leftAction: React.ReactNode;
105
+ }
106
+ );
107
+
108
+ /**
109
+ * Props for ModalBody component.
110
+ *
111
+ * Renders the scrollable main content area of the modal between the header and footer.
112
+ */
113
+ export interface TypeModalBodyProps extends TypeBoxProps {
114
+ /** The main content of the modal body */
115
+ children?: React.ReactNode;
116
+ }
117
+
118
+ /**
119
+ * Props for ModalDescription component.
120
+ *
121
+ * Wraps content with Radix UI's Dialog.Description for accessible modal descriptions
122
+ * that are announced by screen readers.
123
+ */
124
+ export interface TypeModalDescriptionProps extends TypeBoxProps {
125
+ /** The description text content */
126
+ children: React.ReactNode;
127
+ /** Additional props for the Dialog.Description */
128
+ descriptionProps?: Omit<
129
+ React.ComponentPropsWithoutRef<typeof Dialog.Description>,
130
+ "asChild" | "children"
131
+ >;
132
+ }
133
+
134
+ /** Common props shared by all modal variants */
135
+ type TypeModalCommonProps = TypeContainerProps &
136
+ Omit<React.ComponentPropsWithoutRef<"div">, keyof TypeContainerProps> & {
137
+ /** Controls whether the modal is open (controlled mode) */
138
+ open?: boolean;
139
+
140
+ /** Default open state for uncontrolled mode */
141
+ defaultOpen?: boolean;
142
+
143
+ /** body content of the modal */
144
+ children: React.ReactNode;
145
+
146
+ /** Callback when open state changes */
147
+ onOpenChange?: (open: boolean) => void;
148
+
149
+ /** The element that will trigger the modal when clicked.
150
+ * Can be any React element like a button, link, or custom component. */
151
+ modalTrigger?: React.ReactElement<any>;
152
+
153
+ /** Simplified API: Modal description (automatically wrapped in Dialog.Description) */
154
+ description?: string;
155
+
156
+ /**
157
+ * Custom attributes to be added to the modals container
158
+ * Each key will be prepended with "data-" when rendered in the DOM
159
+ */
160
+ data?: Record<string, string | boolean | number>;
161
+
162
+ /** Optional quick actions to render on a modal side rail */
163
+ actions?: TypeModalActionProps[];
164
+
165
+ /** Accessible label for the close button in the rail (defaults to "Close") */
166
+ closeButtonAriaLabel?: string;
167
+ };
168
+
169
+ /**
170
+ * Base props with draggable and showOverlay relationship enforced.
171
+ *
172
+ * When draggable is true, showOverlay must be false or undefined because
173
+ * the overlay would block interaction with content behind the modal,
174
+ * defeating the purpose of being able to drag the modal aside.
175
+ */
176
+ type TypeModalBaseProps =
177
+ | (TypeModalCommonProps & {
178
+ /** Enable draggable functionality */
179
+ draggable: true;
180
+ /**
181
+ * Whether to show the background overlay.
182
+ * Must be false when draggable is true to allow interaction with background content.
183
+ */
184
+ showOverlay?: false;
185
+ })
186
+ | (TypeModalCommonProps & {
187
+ /** Enable draggable functionality */
188
+ draggable?: false;
189
+ /** Whether to show the background overlay (defaults to true) */
190
+ showOverlay?: boolean;
191
+ });
192
+
193
+ /**
194
+ * Modal props with title provided.
195
+ * aria-label is optional since Dialog.Title can serve as the accessible name.
196
+ */
197
+ export type TypeModalPropsWithTitle = TypeModalBaseProps & {
198
+ /** Simplified API: Modal title (creates ModalHeader automatically) */
199
+ title: string;
200
+ /**
201
+ * Simplified API: Modal subtitle (creates ModalHeader automatically).
202
+ *
203
+ * This is automatically wrapped in `Dialog.Description` from Radix UI,
204
+ * which provides accessible description text for screen readers. The subtitle
205
+ * provides additional context about the modal and is announced alongside
206
+ * the title when the dialog opens.
207
+ */
208
+ subtitle?: string;
209
+ /** Accessible label for the modal dialog (optional when title or subtitle is provided) */
210
+ "aria-label"?: string;
211
+ };
212
+
213
+ /**
214
+ * Modal props with subtitle provided but no title.
215
+ * aria-label is optional since Dialog.Description can help identify the modal.
216
+ */
217
+ export type TypeModalPropsWithSubtitleOnly = TypeModalBaseProps & {
218
+ /** Simplified API: Modal title (creates ModalHeader automatically) */
219
+ title?: never;
220
+ /**
221
+ * Simplified API: Modal subtitle (creates ModalHeader automatically).
222
+ *
223
+ * This is automatically wrapped in `Dialog.Description` from Radix UI,
224
+ * which provides accessible description text for screen readers. The subtitle
225
+ * provides additional context about the modal and is announced alongside
226
+ * the title when the dialog opens.
227
+ */
228
+ subtitle: string;
229
+ /** Accessible label for the modal dialog (optional when title or subtitle is provided) */
230
+ "aria-label"?: string;
231
+ };
232
+
233
+ /**
234
+ * Modal props without title or subtitle.
235
+ *
236
+ * **IMPORTANT**: When no header (title or subtitle) is provided,
237
+ * `aria-label` is REQUIRED for accessibility compliance.
238
+ *
239
+ * Without a visible header, screen readers need `aria-label` to identify
240
+ * what the modal dialog is about.
241
+ *
242
+ * @example
243
+ * // ✅ Valid - provides aria-label when no header
244
+ * <Modal aria-label="Delete confirmation dialog">
245
+ * <p>Are you sure you want to delete this item?</p>
246
+ * </Modal>
247
+ *
248
+ * @example
249
+ * // ❌ Invalid - missing required aria-label
250
+ * <Modal>
251
+ * <p>Are you sure you want to delete this item?</p>
252
+ * </Modal>
253
+ */
254
+ export type TypeModalPropsWithoutHeader = TypeModalBaseProps & {
255
+ /** Simplified API: Modal title (creates ModalHeader automatically) - not allowed when no header */
256
+ title?: never;
257
+ /** Simplified API: Modal subtitle (creates ModalHeader automatically) - not allowed when no header */
258
+ subtitle?: never;
259
+ /**
260
+ * **REQUIRED: aria-label must be provided when no title or subtitle is given**
261
+ *
262
+ * Accessible label for the modal dialog. This is required for accessibility
263
+ * when the modal has no visible header. Screen readers use this to announce
264
+ * what the modal is about.
265
+ *
266
+ * **Error**: If you see a TypeScript error saying this property is missing,
267
+ * it means you need to either:
268
+ * 1. Provide `aria-label` (required when no header), OR
269
+ * 2. Provide `title` or `subtitle` (which makes aria-label optional)
270
+ *
271
+ * If you have a title or subtitle, this prop is optional.
272
+ */
273
+ "aria-label": string;
274
+ };
275
+
276
+ /**
277
+ * Modal component props with discriminated union based on header presence.
278
+ *
279
+ * **Accessibility Requirements:**
280
+ * - ✅ **With header** (title or subtitle provided): `aria-label` is optional
281
+ * - ❌ **Without header** (no title, no subtitle): `aria-label` is **REQUIRED**
282
+ *
283
+ * TypeScript will error if you forget to provide `aria-label` when no header is present.
284
+ * This ensures accessibility compliance and better screen reader support.
285
+ *
286
+ * @example
287
+ * // ✅ Valid - has title, aria-label optional
288
+ * <Modal title="Delete Item" />
289
+ *
290
+ * @example
291
+ * // ✅ Valid - has subtitle only, aria-label optional
292
+ * <Modal subtitle="Confirmation" />
293
+ *
294
+ * @example
295
+ * // ✅ Valid - no header, aria-label provided
296
+ * <Modal aria-label="Delete confirmation dialog" />
297
+ *
298
+ * @example
299
+ * // ❌ TypeScript Error - no header and missing required aria-label
300
+ * <Modal /> // Error: Property 'aria-label' is missing
301
+ */
302
+ export type TypeModalProps =
303
+ | TypeModalPropsWithTitle
304
+ | TypeModalPropsWithSubtitleOnly
305
+ | TypeModalPropsWithoutHeader;
306
+
307
+ /**
308
+ * Props for ModalRail component.
309
+ *
310
+ * Container for floating action buttons displayed alongside the modal.
311
+ */
312
+ export type TypeModalRailProps = {
313
+ /** ModalAction components to display in the rail */
314
+ children?: React.ReactNode;
315
+ };
316
+
317
+ /**
318
+ * Base props for modal action buttons.
319
+ *
320
+ * Extends standard button HTML attributes with modal-specific properties.
321
+ */
322
+ type ModalActionBase = Omit<
323
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
324
+ "onClick"
325
+ > & {
326
+ /** Icon name from the Seeds icon set */
327
+ iconName?: TypeIconName;
328
+ /** Optional click handler; ignored for type "close" */
329
+ onClick?: () => void;
330
+ /** Accessible name for the action button */
331
+ "aria-label": string;
332
+ };
333
+
334
+ /**
335
+ * Props for ModalAction component.
336
+ *
337
+ * Discriminated union supporting two action types:
338
+ * - "close": Automatically closes the modal when clicked (uses Dialog.Close)
339
+ * - "button": Custom action with user-defined onClick handler
340
+ */
341
+ export type TypeModalActionProps =
342
+ | (ModalActionBase & { actionType: "close" }) // uses Dialog.Close
343
+ | (ModalActionBase & { actionType?: "button" }); // regular button action