@sproutsocial/seeds-react-modal 2.2.5 → 2.4.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.
- package/.turbo/turbo-build.log +19 -19
- package/CHANGELOG.md +41 -0
- package/dist/{ModalAction-gIgCE73I.d.mts → ModalAction-BHG3Zbd9.d.mts} +151 -6
- package/dist/{ModalAction-gIgCE73I.d.ts → ModalAction-BHG3Zbd9.d.ts} +151 -6
- package/dist/esm/{chunk-UP2XQN57.js → chunk-TQ44T5IM.js} +114 -17
- package/dist/esm/chunk-TQ44T5IM.js.map +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/v2/index.js +9 -3
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +87 -19
- package/dist/index.js.map +1 -1
- package/dist/v2/index.d.mts +77 -3
- package/dist/v2/index.d.ts +77 -3
- package/dist/v2/index.js +121 -21
- package/dist/v2/index.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/v2/Modal.test.tsx +972 -0
- package/src/v2/Modal.tsx +189 -0
- package/src/v2/ModalTypes.ts +57 -10
- package/src/v2/ModalV2.stories.tsx +278 -3
- package/src/v2/components/ModalContent.tsx +13 -1
- package/src/v2/components/ModalExternalTrigger.tsx +104 -0
- package/src/v2/components/index.ts +1 -0
- package/src/v2/index.ts +7 -1
- package/dist/esm/chunk-UP2XQN57.js.map +0 -1
package/src/v2/Modal.tsx
CHANGED
|
@@ -14,6 +14,14 @@ import {
|
|
|
14
14
|
import type { TypeModalProps } from "./ModalTypes";
|
|
15
15
|
import { getOverlayVariants, useIsMobile } from "./MotionConfig";
|
|
16
16
|
|
|
17
|
+
// Type aliases for Dialog.Content event handlers
|
|
18
|
+
type InteractOutsideHandler = NonNullable<
|
|
19
|
+
React.ComponentPropsWithoutRef<typeof Dialog.Content>["onInteractOutside"]
|
|
20
|
+
>;
|
|
21
|
+
type EscapeKeyDownHandler = NonNullable<
|
|
22
|
+
React.ComponentPropsWithoutRef<typeof Dialog.Content>["onEscapeKeyDown"]
|
|
23
|
+
>;
|
|
24
|
+
|
|
17
25
|
/**
|
|
18
26
|
* Accessible modal dialog component built on Radix UI Dialog primitives.
|
|
19
27
|
*
|
|
@@ -61,6 +69,16 @@ const Modal = (props: TypeModalProps) => {
|
|
|
61
69
|
closeButtonAriaLabel = "Close",
|
|
62
70
|
closeButtonProps,
|
|
63
71
|
zIndex = 6,
|
|
72
|
+
// Extract Dialog.Content event handlers
|
|
73
|
+
onOpenAutoFocus,
|
|
74
|
+
onCloseAutoFocus,
|
|
75
|
+
onEscapeKeyDown,
|
|
76
|
+
onPointerDownOutside,
|
|
77
|
+
onFocusOutside,
|
|
78
|
+
onInteractOutside,
|
|
79
|
+
// Extract convenience boolean props
|
|
80
|
+
disableOutsideClickClose = false,
|
|
81
|
+
disableEscapeKeyClose = false,
|
|
64
82
|
...rest
|
|
65
83
|
} = props;
|
|
66
84
|
|
|
@@ -104,6 +122,27 @@ const Modal = (props: TypeModalProps) => {
|
|
|
104
122
|
? DraggableModalContent
|
|
105
123
|
: StaticModalContent;
|
|
106
124
|
|
|
125
|
+
// Wrap event handlers to support convenience boolean props
|
|
126
|
+
const wrappedOnInteractOutside = React.useCallback<InteractOutsideHandler>(
|
|
127
|
+
(e) => {
|
|
128
|
+
if (disableOutsideClickClose) {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
}
|
|
131
|
+
onInteractOutside?.(e);
|
|
132
|
+
},
|
|
133
|
+
[disableOutsideClickClose, onInteractOutside]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const wrappedOnEscapeKeyDown = React.useCallback<EscapeKeyDownHandler>(
|
|
137
|
+
(e) => {
|
|
138
|
+
if (disableEscapeKeyClose) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
}
|
|
141
|
+
onEscapeKeyDown?.(e);
|
|
142
|
+
},
|
|
143
|
+
[disableEscapeKeyClose, onEscapeKeyDown]
|
|
144
|
+
);
|
|
145
|
+
|
|
107
146
|
return (
|
|
108
147
|
<Dialog.Root
|
|
109
148
|
open={open}
|
|
@@ -130,6 +169,7 @@ const Modal = (props: TypeModalProps) => {
|
|
|
130
169
|
<StyledOverlay
|
|
131
170
|
data-slot="modal-overlay"
|
|
132
171
|
data-qa-modal-overlay
|
|
172
|
+
data-testid="modal-overlay"
|
|
133
173
|
allowInteraction={draggable}
|
|
134
174
|
/>
|
|
135
175
|
</StyledMotionOverlay>
|
|
@@ -141,6 +181,14 @@ const Modal = (props: TypeModalProps) => {
|
|
|
141
181
|
draggable={draggable}
|
|
142
182
|
zIndex={zIndex}
|
|
143
183
|
rest={rest}
|
|
184
|
+
dialogContentProps={{
|
|
185
|
+
onOpenAutoFocus,
|
|
186
|
+
onCloseAutoFocus,
|
|
187
|
+
onEscapeKeyDown: wrappedOnEscapeKeyDown,
|
|
188
|
+
onPointerDownOutside,
|
|
189
|
+
onFocusOutside,
|
|
190
|
+
onInteractOutside: wrappedOnInteractOutside,
|
|
191
|
+
}}
|
|
144
192
|
>
|
|
145
193
|
{/* Floating actions rail - always show a close by default */}
|
|
146
194
|
<ModalRail>
|
|
@@ -178,4 +226,145 @@ const Modal = (props: TypeModalProps) => {
|
|
|
178
226
|
);
|
|
179
227
|
};
|
|
180
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Hook for adding proper ARIA attributes to external modal triggers.
|
|
231
|
+
*
|
|
232
|
+
* ⚠️ **NOT RECOMMENDED** - Prefer using modalTrigger prop or ModalTrigger component.
|
|
233
|
+
* Use this hook ONLY as a last resort when architectural constraints prevent keeping
|
|
234
|
+
* the trigger inside the Modal component tree.
|
|
235
|
+
*
|
|
236
|
+
* **Important Limitations:**
|
|
237
|
+
* - This hook only provides ARIA attributes (aria-haspopup, aria-expanded)
|
|
238
|
+
* - Focus restoration is NOT automatic - you must manually handle it with refs
|
|
239
|
+
* - Radix UI cannot track external triggers for proper accessibility
|
|
240
|
+
*
|
|
241
|
+
* **Why modalTrigger prop is better:**
|
|
242
|
+
* - Automatic ARIA attributes
|
|
243
|
+
* - Automatic focus restoration
|
|
244
|
+
* - Better touch device support
|
|
245
|
+
* - Follows WAI-ARIA Dialog best practices
|
|
246
|
+
*
|
|
247
|
+
* @param isOpen - Current open state of the modal
|
|
248
|
+
* @param modalId - Optional ID of the modal element for aria-controls
|
|
249
|
+
* @returns Object with ARIA attributes to spread onto trigger element
|
|
250
|
+
*
|
|
251
|
+
* @example
|
|
252
|
+
* ```tsx
|
|
253
|
+
* // Manual focus restoration required
|
|
254
|
+
* const [isOpen, setIsOpen] = useState(false);
|
|
255
|
+
* const triggerRef = useRef<HTMLButtonElement>(null);
|
|
256
|
+
* const triggerProps = useModalTriggerProps(isOpen);
|
|
257
|
+
*
|
|
258
|
+
* return (
|
|
259
|
+
* <>
|
|
260
|
+
* <Button
|
|
261
|
+
* ref={triggerRef}
|
|
262
|
+
* {...triggerProps}
|
|
263
|
+
* onClick={() => setIsOpen(true)}
|
|
264
|
+
* >
|
|
265
|
+
* Open Modal
|
|
266
|
+
* </Button>
|
|
267
|
+
* <Modal
|
|
268
|
+
* open={isOpen}
|
|
269
|
+
* onOpenChange={setIsOpen}
|
|
270
|
+
* onCloseAutoFocus={(e) => {
|
|
271
|
+
* e.preventDefault();
|
|
272
|
+
* triggerRef.current?.focus();
|
|
273
|
+
* }}
|
|
274
|
+
* >
|
|
275
|
+
* <ModalBody>Content</ModalBody>
|
|
276
|
+
* </Modal>
|
|
277
|
+
* </>
|
|
278
|
+
* );
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
export function useModalTriggerProps(
|
|
282
|
+
isOpen: boolean,
|
|
283
|
+
modalId?: string
|
|
284
|
+
): {
|
|
285
|
+
"aria-haspopup": "dialog";
|
|
286
|
+
"aria-expanded": boolean;
|
|
287
|
+
"aria-controls"?: string;
|
|
288
|
+
} {
|
|
289
|
+
return React.useMemo(
|
|
290
|
+
() => ({
|
|
291
|
+
"aria-haspopup": "dialog" as const,
|
|
292
|
+
"aria-expanded": isOpen,
|
|
293
|
+
...(modalId ? { "aria-controls": modalId } : {}),
|
|
294
|
+
}),
|
|
295
|
+
[isOpen, modalId]
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Hook for managing external modal triggers with automatic focus restoration.
|
|
301
|
+
*
|
|
302
|
+
* ⚠️ **NOT RECOMMENDED** - Prefer using modalTrigger prop or ModalTrigger component.
|
|
303
|
+
* Use this hook ONLY as a last resort when architectural constraints prevent keeping
|
|
304
|
+
* the trigger inside the Modal component tree.
|
|
305
|
+
*
|
|
306
|
+
* This hook improves upon useModalTriggerProps by managing the trigger ref internally
|
|
307
|
+
* and providing the onCloseAutoFocus callback, eliminating the need for manual
|
|
308
|
+
* focus restoration boilerplate.
|
|
309
|
+
*
|
|
310
|
+
* **Improvements over useModalTriggerProps:**
|
|
311
|
+
* - ✅ No manual ref creation
|
|
312
|
+
* - ✅ Automatic focus restoration via onCloseAutoFocus callback
|
|
313
|
+
* - ✅ Automatic ARIA attributes
|
|
314
|
+
*
|
|
315
|
+
* **Why modalTrigger prop is still better:**
|
|
316
|
+
* - Better touch device support
|
|
317
|
+
* - Follows WAI-ARIA Dialog best practices
|
|
318
|
+
* - Less boilerplate overall
|
|
319
|
+
*
|
|
320
|
+
* @param modalId - Optional ID of the modal element for aria-controls
|
|
321
|
+
* @returns Object with triggerRef, ARIA props, and onCloseAutoFocus callback
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```tsx
|
|
325
|
+
* const [isOpen, setIsOpen] = useState(false);
|
|
326
|
+
* const { triggerRef, triggerProps, onCloseAutoFocus } = useModalExternalTrigger();
|
|
327
|
+
*
|
|
328
|
+
* return (
|
|
329
|
+
* <>
|
|
330
|
+
* <Button
|
|
331
|
+
* ref={triggerRef}
|
|
332
|
+
* {...triggerProps(isOpen)}
|
|
333
|
+
* onClick={() => setIsOpen(true)}
|
|
334
|
+
* >
|
|
335
|
+
* Open Modal
|
|
336
|
+
* </Button>
|
|
337
|
+
* <Modal
|
|
338
|
+
* open={isOpen}
|
|
339
|
+
* onOpenChange={setIsOpen}
|
|
340
|
+
* onCloseAutoFocus={onCloseAutoFocus}
|
|
341
|
+
* >
|
|
342
|
+
* <ModalBody>Content</ModalBody>
|
|
343
|
+
* </Modal>
|
|
344
|
+
* </>
|
|
345
|
+
* );
|
|
346
|
+
* ```
|
|
347
|
+
*/
|
|
348
|
+
export function useModalExternalTrigger<
|
|
349
|
+
T extends HTMLElement = HTMLButtonElement
|
|
350
|
+
>(modalId?: string) {
|
|
351
|
+
const triggerRef = React.useRef<T>(null);
|
|
352
|
+
|
|
353
|
+
const triggerProps = React.useCallback(
|
|
354
|
+
(isOpen: boolean) => ({
|
|
355
|
+
"aria-haspopup": "dialog" as const,
|
|
356
|
+
"aria-expanded": isOpen,
|
|
357
|
+
...(modalId ? { "aria-controls": modalId } : {}),
|
|
358
|
+
}),
|
|
359
|
+
[modalId]
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const onCloseAutoFocus = React.useCallback((e: Event) => {
|
|
363
|
+
e.preventDefault();
|
|
364
|
+
triggerRef.current?.focus();
|
|
365
|
+
}, []);
|
|
366
|
+
|
|
367
|
+
return { triggerRef, triggerProps, onCloseAutoFocus };
|
|
368
|
+
}
|
|
369
|
+
|
|
181
370
|
export default Modal;
|
package/src/v2/ModalTypes.ts
CHANGED
|
@@ -145,6 +145,29 @@ export type TypeModalActionProps =
|
|
|
145
145
|
// MODAL PROPS - Main Modal component types
|
|
146
146
|
// =============================================================================
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Event handler props from Radix UI Dialog.Content.
|
|
150
|
+
* These control modal interaction behavior like closing on outside clicks or escape key.
|
|
151
|
+
*/
|
|
152
|
+
type DialogContentEventHandlers = Pick<
|
|
153
|
+
React.ComponentPropsWithoutRef<typeof Dialog.Content>,
|
|
154
|
+
| "onOpenAutoFocus"
|
|
155
|
+
| "onCloseAutoFocus"
|
|
156
|
+
| "onEscapeKeyDown"
|
|
157
|
+
| "onPointerDownOutside"
|
|
158
|
+
| "onFocusOutside"
|
|
159
|
+
| "onInteractOutside"
|
|
160
|
+
>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Dialog.Content event handlers excluding onInteractOutside.
|
|
164
|
+
* Used for draggable modals where onInteractOutside conflicts with the draggable UX.
|
|
165
|
+
*/
|
|
166
|
+
type DialogContentEventHandlersWithoutInteractOutside = Omit<
|
|
167
|
+
DialogContentEventHandlers,
|
|
168
|
+
"onInteractOutside"
|
|
169
|
+
>;
|
|
170
|
+
|
|
148
171
|
/**
|
|
149
172
|
* Base common props shared by all modal variants (without close button props).
|
|
150
173
|
*/
|
|
@@ -182,6 +205,18 @@ type TypeModalCommonPropsBase = TypeContainerProps &
|
|
|
182
205
|
|
|
183
206
|
/** Controls the z-index CSS property (defaults to 6 to match Modal v1) */
|
|
184
207
|
zIndex?: number;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Prevents the modal from closing when clicking outside.
|
|
211
|
+
* The onInteractOutside handler will still be called if provided.
|
|
212
|
+
*/
|
|
213
|
+
disableOutsideClickClose?: boolean;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Prevents the modal from closing when pressing the Escape key.
|
|
217
|
+
* The onEscapeKeyDown handler will still be called if provided.
|
|
218
|
+
*/
|
|
219
|
+
disableEscapeKeyClose?: boolean;
|
|
185
220
|
};
|
|
186
221
|
|
|
187
222
|
/**
|
|
@@ -244,18 +279,30 @@ type TypeModalCommonProps =
|
|
|
244
279
|
/**
|
|
245
280
|
* Base props with draggable and showOverlay relationship enforced.
|
|
246
281
|
*
|
|
247
|
-
* When draggable is true
|
|
248
|
-
* would block interaction with content behind the modal
|
|
282
|
+
* When draggable is true:
|
|
283
|
+
* - showOverlay must be false (overlay would block interaction with content behind the modal)
|
|
284
|
+
* - onInteractOutside is not allowed (conflicts with draggable UX which needs to keep modal open)
|
|
285
|
+
* - disableOutsideClickClose is not allowed (since onInteractOutside is not supported)
|
|
286
|
+
*
|
|
287
|
+
* When draggable is false (or undefined):
|
|
288
|
+
* - All Dialog.Content event handlers are available including onInteractOutside
|
|
289
|
+
* - disableOutsideClickClose can be used
|
|
249
290
|
*/
|
|
250
291
|
type TypeModalBaseProps =
|
|
251
|
-
| (TypeModalCommonProps &
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
292
|
+
| (TypeModalCommonProps &
|
|
293
|
+
DialogContentEventHandlersWithoutInteractOutside & {
|
|
294
|
+
draggable: true;
|
|
295
|
+
showOverlay?: false;
|
|
296
|
+
/** onInteractOutside is not supported for draggable modals */
|
|
297
|
+
onInteractOutside?: never;
|
|
298
|
+
/** disableOutsideClickClose is not supported for draggable modals */
|
|
299
|
+
disableOutsideClickClose?: never;
|
|
300
|
+
})
|
|
301
|
+
| (TypeModalCommonProps &
|
|
302
|
+
DialogContentEventHandlers & {
|
|
303
|
+
draggable?: false;
|
|
304
|
+
showOverlay?: boolean;
|
|
305
|
+
});
|
|
259
306
|
|
|
260
307
|
/**
|
|
261
308
|
* Modal props with title provided.
|
|
@@ -4,6 +4,13 @@ import { Box } from "@sproutsocial/seeds-react-box";
|
|
|
4
4
|
import { Button } from "@sproutsocial/seeds-react-button";
|
|
5
5
|
import Text from "@sproutsocial/seeds-react-text";
|
|
6
6
|
import { FormField } from "@sproutsocial/seeds-react-form-field";
|
|
7
|
+
import {
|
|
8
|
+
MenuContent,
|
|
9
|
+
MenuItem,
|
|
10
|
+
MenuToggleButton,
|
|
11
|
+
SingleSelectMenu,
|
|
12
|
+
type TypeSingleSelectMenuProps,
|
|
13
|
+
} from "@sproutsocial/seeds-react-menu";
|
|
7
14
|
import {
|
|
8
15
|
Modal,
|
|
9
16
|
ModalHeader,
|
|
@@ -11,6 +18,8 @@ import {
|
|
|
11
18
|
ModalBody,
|
|
12
19
|
ModalCloseWrapper,
|
|
13
20
|
ModalCustomFooter,
|
|
21
|
+
ModalExternalTrigger,
|
|
22
|
+
useModalExternalTrigger,
|
|
14
23
|
} from ".";
|
|
15
24
|
|
|
16
25
|
const meta: Meta<typeof Modal> = {
|
|
@@ -83,9 +92,11 @@ export const Default: Story = {
|
|
|
83
92
|
>
|
|
84
93
|
<ModalHeader title="Modal Title" subtitle="This is a subtitle" />
|
|
85
94
|
<ModalBody>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
<Text>
|
|
96
|
+
This modal uses uncontrolled state - no need to manage open/close
|
|
97
|
+
state! The modalTrigger prop and floating close button handle
|
|
98
|
+
everything.
|
|
99
|
+
</Text>
|
|
89
100
|
</ModalBody>
|
|
90
101
|
<ModalFooter
|
|
91
102
|
cancelButton={<Button>Cancel</Button>}
|
|
@@ -876,3 +887,267 @@ export const CustomFooterOverride: Story = {
|
|
|
876
887
|
);
|
|
877
888
|
},
|
|
878
889
|
};
|
|
890
|
+
|
|
891
|
+
export const SingleSelectMenuInModal: Story = {
|
|
892
|
+
render: () => {
|
|
893
|
+
const [selectedItem, setSelectedItem] =
|
|
894
|
+
useState<TypeSingleSelectMenuProps["selectedItem"]>(null);
|
|
895
|
+
|
|
896
|
+
return (
|
|
897
|
+
<>
|
|
898
|
+
<Modal
|
|
899
|
+
modalTrigger={<Button appearance="primary">Open Modal</Button>}
|
|
900
|
+
showOverlay={true}
|
|
901
|
+
aria-label="Demo Modal"
|
|
902
|
+
closeButtonProps={{ "aria-label": "Close" }}
|
|
903
|
+
>
|
|
904
|
+
<ModalHeader title="Select an Option" />
|
|
905
|
+
<ModalBody>
|
|
906
|
+
<Box p={400}>
|
|
907
|
+
<SingleSelectMenu
|
|
908
|
+
popoutProps={{ appendToBody: false }}
|
|
909
|
+
selectedItem={selectedItem}
|
|
910
|
+
onSelectedItemChange={({ selectedItem: item }) =>
|
|
911
|
+
setSelectedItem(item)
|
|
912
|
+
}
|
|
913
|
+
menuToggleElement={
|
|
914
|
+
<MenuToggleButton>
|
|
915
|
+
{selectedItem?.id ?? "Select..."}
|
|
916
|
+
</MenuToggleButton>
|
|
917
|
+
}
|
|
918
|
+
>
|
|
919
|
+
<MenuContent>
|
|
920
|
+
<MenuItem id="option-1">Option 1</MenuItem>
|
|
921
|
+
<MenuItem id="option-2">Option 2</MenuItem>
|
|
922
|
+
<MenuItem id="option-3">Option 3</MenuItem>
|
|
923
|
+
</MenuContent>
|
|
924
|
+
</SingleSelectMenu>
|
|
925
|
+
</Box>
|
|
926
|
+
</ModalBody>
|
|
927
|
+
</Modal>
|
|
928
|
+
</>
|
|
929
|
+
);
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Example using the useModalExternalTrigger hook for external triggers.
|
|
935
|
+
*
|
|
936
|
+
* ⚠️ NOT RECOMMENDED - This is a last resort pattern. Prefer using modalTrigger prop.
|
|
937
|
+
*
|
|
938
|
+
* The hook provides:
|
|
939
|
+
* - triggerRef: Ref to attach to your trigger element
|
|
940
|
+
* - triggerProps: Function that returns ARIA props (pass isOpen state)
|
|
941
|
+
* - onCloseAutoFocus: Callback for Modal to restore focus
|
|
942
|
+
*
|
|
943
|
+
* This eliminates manual ref creation and focus restoration boilerplate.
|
|
944
|
+
*/
|
|
945
|
+
export const ExternalTriggerWithHook: Story = {
|
|
946
|
+
render: () => {
|
|
947
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
948
|
+
const { triggerRef, triggerProps, onCloseAutoFocus } =
|
|
949
|
+
useModalExternalTrigger();
|
|
950
|
+
|
|
951
|
+
return (
|
|
952
|
+
<Box>
|
|
953
|
+
<Text mb={400} fontWeight="semibold">
|
|
954
|
+
⚠️ LAST RESORT: useModalExternalTrigger Hook
|
|
955
|
+
</Text>
|
|
956
|
+
<Text mb={400}>
|
|
957
|
+
Only use when the trigger absolutely cannot be near the Modal (e.g.,
|
|
958
|
+
trigger in header, modal at bottom of app tree).
|
|
959
|
+
</Text>
|
|
960
|
+
|
|
961
|
+
<Text mb={400} fontWeight="semibold">
|
|
962
|
+
Benefits:
|
|
963
|
+
</Text>
|
|
964
|
+
<Box as="ul" pl={400} mb={400}>
|
|
965
|
+
<li>
|
|
966
|
+
<Text>
|
|
967
|
+
Works with any trigger element (Button, custom components, etc.)
|
|
968
|
+
</Text>
|
|
969
|
+
</li>
|
|
970
|
+
<li>
|
|
971
|
+
<Text>
|
|
972
|
+
Automatic focus restoration via onCloseAutoFocus callback
|
|
973
|
+
</Text>
|
|
974
|
+
</li>
|
|
975
|
+
<li>
|
|
976
|
+
<Text>No manual ref creation needed</Text>
|
|
977
|
+
</li>
|
|
978
|
+
</Box>
|
|
979
|
+
|
|
980
|
+
<Button
|
|
981
|
+
ref={triggerRef}
|
|
982
|
+
{...triggerProps(isOpen)}
|
|
983
|
+
appearance="primary"
|
|
984
|
+
onClick={() => setIsOpen(true)}
|
|
985
|
+
>
|
|
986
|
+
Open Modal (External Trigger)
|
|
987
|
+
</Button>
|
|
988
|
+
|
|
989
|
+
<Modal
|
|
990
|
+
open={isOpen}
|
|
991
|
+
onOpenChange={setIsOpen}
|
|
992
|
+
onCloseAutoFocus={onCloseAutoFocus}
|
|
993
|
+
aria-label="External Trigger Hook Modal"
|
|
994
|
+
closeButtonAriaLabel="Close Modal"
|
|
995
|
+
>
|
|
996
|
+
<ModalHeader
|
|
997
|
+
title="External Trigger with Hook"
|
|
998
|
+
subtitle="Using useModalExternalTrigger"
|
|
999
|
+
/>
|
|
1000
|
+
<ModalBody>
|
|
1001
|
+
<Text mb={400}>
|
|
1002
|
+
The useModalExternalTrigger hook simplifies external trigger setup
|
|
1003
|
+
by:
|
|
1004
|
+
</Text>
|
|
1005
|
+
<Box as="ul" pl={400}>
|
|
1006
|
+
<li>
|
|
1007
|
+
<Text>Managing the trigger ref internally</Text>
|
|
1008
|
+
</li>
|
|
1009
|
+
<li>
|
|
1010
|
+
<Text>Providing ARIA props via triggerProps function</Text>
|
|
1011
|
+
</li>
|
|
1012
|
+
<li>
|
|
1013
|
+
<Text>
|
|
1014
|
+
Handling focus restoration via onCloseAutoFocus callback
|
|
1015
|
+
</Text>
|
|
1016
|
+
</li>
|
|
1017
|
+
</Box>
|
|
1018
|
+
<Text mt={400}>
|
|
1019
|
+
This eliminates the boilerplate of manual ref management and
|
|
1020
|
+
custom onCloseAutoFocus implementation.
|
|
1021
|
+
</Text>
|
|
1022
|
+
</ModalBody>
|
|
1023
|
+
<ModalFooter
|
|
1024
|
+
cancelButton={
|
|
1025
|
+
<ModalCloseWrapper>
|
|
1026
|
+
<Button>Cancel</Button>
|
|
1027
|
+
</ModalCloseWrapper>
|
|
1028
|
+
}
|
|
1029
|
+
primaryButton={
|
|
1030
|
+
<ModalCloseWrapper>
|
|
1031
|
+
<Button appearance="primary">Confirm</Button>
|
|
1032
|
+
</ModalCloseWrapper>
|
|
1033
|
+
}
|
|
1034
|
+
/>
|
|
1035
|
+
</Modal>
|
|
1036
|
+
</Box>
|
|
1037
|
+
);
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Example using the ModalExternalTrigger component for external triggers.
|
|
1043
|
+
*
|
|
1044
|
+
* ⚠️ NOT RECOMMENDED - This is a last resort pattern. Prefer using modalTrigger prop.
|
|
1045
|
+
*
|
|
1046
|
+
* ModalExternalTrigger is a Button variant with built-in ARIA attributes.
|
|
1047
|
+
* Use it when you need an external trigger that is specifically a Button.
|
|
1048
|
+
*
|
|
1049
|
+
* Note: Focus restoration still requires manual onCloseAutoFocus implementation.
|
|
1050
|
+
*/
|
|
1051
|
+
export const ExternalTriggerComponent: Story = {
|
|
1052
|
+
render: () => {
|
|
1053
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
1054
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
1055
|
+
|
|
1056
|
+
return (
|
|
1057
|
+
<Box>
|
|
1058
|
+
<Text mb={400} fontWeight="semibold">
|
|
1059
|
+
⚠️ LAST RESORT: ModalExternalTrigger Component
|
|
1060
|
+
</Text>
|
|
1061
|
+
<Text mb={400}>
|
|
1062
|
+
Only use when the trigger absolutely cannot be near the Modal AND
|
|
1063
|
+
you're using a Seeds Button specifically.
|
|
1064
|
+
</Text>
|
|
1065
|
+
|
|
1066
|
+
<Text mb={400} fontWeight="semibold">
|
|
1067
|
+
Benefits:
|
|
1068
|
+
</Text>
|
|
1069
|
+
<Box as="ul" pl={400} mb={400}>
|
|
1070
|
+
<li>
|
|
1071
|
+
<Text>
|
|
1072
|
+
Automatic ARIA attributes (aria-haspopup, aria-expanded, etc.)
|
|
1073
|
+
</Text>
|
|
1074
|
+
</li>
|
|
1075
|
+
<li>
|
|
1076
|
+
<Text>
|
|
1077
|
+
All Button props supported (appearance, size, disabled, etc.)
|
|
1078
|
+
</Text>
|
|
1079
|
+
</li>
|
|
1080
|
+
<li>
|
|
1081
|
+
<Text>Cleaner than hook for Button-only triggers</Text>
|
|
1082
|
+
</li>
|
|
1083
|
+
</Box>
|
|
1084
|
+
|
|
1085
|
+
<Text mb={400} fontWeight="semibold">
|
|
1086
|
+
Limitation:
|
|
1087
|
+
</Text>
|
|
1088
|
+
<Text mb={400}>
|
|
1089
|
+
Focus restoration still requires manual onCloseAutoFocus handling.
|
|
1090
|
+
</Text>
|
|
1091
|
+
|
|
1092
|
+
<ModalExternalTrigger
|
|
1093
|
+
ref={triggerRef}
|
|
1094
|
+
isOpen={isOpen}
|
|
1095
|
+
onTrigger={() => setIsOpen(true)}
|
|
1096
|
+
appearance="primary"
|
|
1097
|
+
size="default"
|
|
1098
|
+
>
|
|
1099
|
+
Open Modal (External Trigger)
|
|
1100
|
+
</ModalExternalTrigger>
|
|
1101
|
+
|
|
1102
|
+
<Modal
|
|
1103
|
+
open={isOpen}
|
|
1104
|
+
onOpenChange={setIsOpen}
|
|
1105
|
+
onCloseAutoFocus={(e) => {
|
|
1106
|
+
e.preventDefault();
|
|
1107
|
+
triggerRef.current?.focus();
|
|
1108
|
+
}}
|
|
1109
|
+
aria-label="External Trigger Component Modal"
|
|
1110
|
+
closeButtonAriaLabel="Close Modal"
|
|
1111
|
+
>
|
|
1112
|
+
<ModalHeader
|
|
1113
|
+
title="External Trigger Component"
|
|
1114
|
+
subtitle="Using ModalExternalTrigger"
|
|
1115
|
+
/>
|
|
1116
|
+
<ModalBody>
|
|
1117
|
+
<Text mb={400}>
|
|
1118
|
+
ModalExternalTrigger extends Seeds Button with automatic ARIA
|
|
1119
|
+
attributes:
|
|
1120
|
+
</Text>
|
|
1121
|
+
<Box as="ul" pl={400}>
|
|
1122
|
+
<li>
|
|
1123
|
+
<Text>aria-haspopup="dialog"</Text>
|
|
1124
|
+
</li>
|
|
1125
|
+
<li>
|
|
1126
|
+
<Text>aria-expanded based on isOpen prop</Text>
|
|
1127
|
+
</li>
|
|
1128
|
+
<li>
|
|
1129
|
+
<Text>aria-controls (if modalId provided)</Text>
|
|
1130
|
+
</li>
|
|
1131
|
+
</Box>
|
|
1132
|
+
<Text mt={400}>
|
|
1133
|
+
Supports all Button props (appearance, size, disabled, etc.).
|
|
1134
|
+
Focus restoration requires manual onCloseAutoFocus handling.
|
|
1135
|
+
</Text>
|
|
1136
|
+
</ModalBody>
|
|
1137
|
+
<ModalFooter
|
|
1138
|
+
cancelButton={
|
|
1139
|
+
<ModalCloseWrapper>
|
|
1140
|
+
<Button>Cancel</Button>
|
|
1141
|
+
</ModalCloseWrapper>
|
|
1142
|
+
}
|
|
1143
|
+
primaryButton={
|
|
1144
|
+
<ModalCloseWrapper>
|
|
1145
|
+
<Button appearance="primary">Confirm</Button>
|
|
1146
|
+
</ModalCloseWrapper>
|
|
1147
|
+
}
|
|
1148
|
+
/>
|
|
1149
|
+
</Modal>
|
|
1150
|
+
</Box>
|
|
1151
|
+
);
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
@@ -119,6 +119,15 @@ interface ModalContentProps {
|
|
|
119
119
|
draggable?: boolean;
|
|
120
120
|
zIndex?: number;
|
|
121
121
|
rest: any;
|
|
122
|
+
dialogContentProps?: Pick<
|
|
123
|
+
React.ComponentPropsWithoutRef<typeof Dialog.Content>,
|
|
124
|
+
| "onOpenAutoFocus"
|
|
125
|
+
| "onCloseAutoFocus"
|
|
126
|
+
| "onEscapeKeyDown"
|
|
127
|
+
| "onPointerDownOutside"
|
|
128
|
+
| "onFocusOutside"
|
|
129
|
+
| "onInteractOutside"
|
|
130
|
+
>;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
/**
|
|
@@ -131,13 +140,14 @@ export const StaticModalContent: React.FC<ModalContentProps> = ({
|
|
|
131
140
|
dataAttributes,
|
|
132
141
|
zIndex,
|
|
133
142
|
rest,
|
|
143
|
+
dialogContentProps,
|
|
134
144
|
}) => {
|
|
135
145
|
const isMobile = useIsMobile();
|
|
136
146
|
const contentVariants = getContentVariants(isMobile, false);
|
|
137
147
|
|
|
138
148
|
return (
|
|
139
149
|
<DragContext.Provider value={null}>
|
|
140
|
-
<Dialog.Content asChild aria-label={label}>
|
|
150
|
+
<Dialog.Content asChild aria-label={label} {...dialogContentProps}>
|
|
141
151
|
<StyledMotionWrapper
|
|
142
152
|
$isMobile={isMobile}
|
|
143
153
|
$zIndex={zIndex}
|
|
@@ -170,6 +180,7 @@ export const DraggableModalContent: React.FC<ModalContentProps> = ({
|
|
|
170
180
|
dataAttributes,
|
|
171
181
|
zIndex,
|
|
172
182
|
rest,
|
|
183
|
+
dialogContentProps,
|
|
173
184
|
}) => {
|
|
174
185
|
const [position, setPosition] = React.useState({ x: 0, y: 0 });
|
|
175
186
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
@@ -274,6 +285,7 @@ export const DraggableModalContent: React.FC<ModalContentProps> = ({
|
|
|
274
285
|
<Dialog.Content
|
|
275
286
|
asChild
|
|
276
287
|
aria-label={label}
|
|
288
|
+
{...dialogContentProps}
|
|
277
289
|
onInteractOutside={handleInteractOutside}
|
|
278
290
|
>
|
|
279
291
|
<StyledMotionWrapper
|