@sproutsocial/seeds-react-modal 1.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.
@@ -0,0 +1,420 @@
1
+ import React, { useState, type FC } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Box } from "@sproutsocial/seeds-react-box";
4
+ import { Button } from "@sproutsocial/seeds-react-button";
5
+ import { FormField } from "@sproutsocial/seeds-react-form-field";
6
+ import { Input } from "@sproutsocial/seeds-react-input";
7
+ import { Text } from "@sproutsocial/seeds-react-text";
8
+ import Modal from "./Modal";
9
+
10
+ export interface StatefulStoryProps<T> {
11
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
12
+ children: FC<{ state: T; setState: (state: T) => void }>;
13
+ initialState: T;
14
+ }
15
+
16
+ export function StatefulStory<T>({
17
+ children,
18
+ initialState,
19
+ }: StatefulStoryProps<T>) {
20
+ const [state, setState] = useState<T>(initialState);
21
+ return children({ state, setState });
22
+ }
23
+
24
+ const meta: Meta<typeof Modal> = {
25
+ title: "Components/Modal",
26
+ component: Modal,
27
+ };
28
+ export default meta;
29
+
30
+ type Story = StoryObj<typeof Modal>;
31
+
32
+ export const Default: Story = {
33
+ render: () => (
34
+ <StatefulStory
35
+ initialState={{
36
+ isOpen: false,
37
+ }}
38
+ >
39
+ {({ setState, state }) => (
40
+ <div>
41
+ <Button
42
+ appearance="primary"
43
+ onClick={() =>
44
+ setState({
45
+ isOpen: !state.isOpen,
46
+ })
47
+ }
48
+ >
49
+ Open Modal
50
+ </Button>
51
+ <Box height="2000px" color="text.body">
52
+ Really tall box.
53
+ </Box>
54
+ <Modal
55
+ appElementSelector="#root"
56
+ isOpen={state.isOpen}
57
+ onClose={() =>
58
+ setState({
59
+ isOpen: !state.isOpen,
60
+ })
61
+ }
62
+ closeButtonLabel="Close this dialog"
63
+ label="Example Modal"
64
+ >
65
+ <React.Fragment>
66
+ <Modal.Header
67
+ title="Assign Chatbot"
68
+ subtitle="The chatbot will respond to customers from this profile."
69
+ />
70
+ <Modal.Content>
71
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
72
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
73
+ {/* ...existing content... */}
74
+ </Modal.Content>
75
+ <Modal.Footer>
76
+ <Button appearance="primary" width={1}>
77
+ Full-Width Button
78
+ </Button>
79
+ </Modal.Footer>
80
+ </React.Fragment>
81
+ </Modal>
82
+ </div>
83
+ )}
84
+ </StatefulStory>
85
+ ),
86
+ };
87
+
88
+ export const NotCloseable: Story = {
89
+ render: () => (
90
+ <StatefulStory
91
+ initialState={{
92
+ isOpen: false,
93
+ }}
94
+ >
95
+ {({ setState, state }) => (
96
+ <div>
97
+ <Button
98
+ appearance="primary"
99
+ onClick={() =>
100
+ setState({
101
+ isOpen: !state.isOpen,
102
+ })
103
+ }
104
+ >
105
+ Open Modal
106
+ </Button>
107
+ <Box height="2000px" color="text.body">
108
+ Really tall box.
109
+ </Box>
110
+ <Modal
111
+ appElementSelector="#root"
112
+ isOpen={state.isOpen}
113
+ closeButtonLabel="n/a"
114
+ label="Example Modal"
115
+ >
116
+ <React.Fragment>
117
+ <Modal.Content>
118
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
119
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
120
+ {/* ...existing content... */}
121
+ </Modal.Content>
122
+ <Modal.Footer>
123
+ <Button
124
+ appearance="primary"
125
+ width={1}
126
+ onClick={() =>
127
+ setState({
128
+ isOpen: !state.isOpen,
129
+ })
130
+ }
131
+ >
132
+ Must click to close
133
+ </Button>
134
+ </Modal.Footer>
135
+ </React.Fragment>
136
+ </Modal>
137
+ </div>
138
+ )}
139
+ </StatefulStory>
140
+ ),
141
+ };
142
+
143
+ export const CustomHeader: Story = {
144
+ render: () => (
145
+ <StatefulStory
146
+ initialState={{
147
+ isOpen: false,
148
+ }}
149
+ >
150
+ {({ setState, state }) => (
151
+ <div>
152
+ <Button
153
+ appearance="primary"
154
+ onClick={() =>
155
+ setState({
156
+ isOpen: !state.isOpen,
157
+ })
158
+ }
159
+ >
160
+ Open Modal
161
+ </Button>
162
+ <Modal
163
+ appElementSelector="#root"
164
+ isOpen={state.isOpen}
165
+ onClose={() =>
166
+ setState({
167
+ isOpen: !state.isOpen,
168
+ })
169
+ }
170
+ closeButtonLabel="Close this dialog"
171
+ label="Example Modal"
172
+ >
173
+ <React.Fragment>
174
+ <Modal.Header title="" subtitle="" bordered>
175
+ <Box width="100%" bg="purple.400">
176
+ Custom header
177
+ </Box>
178
+ </Modal.Header>
179
+ <Modal.Content>
180
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
181
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
182
+ {/* ...existing content... */}
183
+ </Modal.Content>
184
+ <Modal.Footer>
185
+ <Button appearance="primary" width={1}>
186
+ Full-Width Button
187
+ </Button>
188
+ </Modal.Footer>
189
+ </React.Fragment>
190
+ </Modal>
191
+ </div>
192
+ )}
193
+ </StatefulStory>
194
+ ),
195
+ };
196
+
197
+ export const NoFooter: Story = {
198
+ render: () => (
199
+ <StatefulStory
200
+ initialState={{
201
+ isOpen: false,
202
+ }}
203
+ >
204
+ {({ setState, state }) => (
205
+ <div>
206
+ <Button
207
+ appearance="primary"
208
+ onClick={() =>
209
+ setState({
210
+ isOpen: !state.isOpen,
211
+ })
212
+ }
213
+ >
214
+ Open Modal
215
+ </Button>
216
+ <Modal
217
+ appElementSelector="#root"
218
+ isOpen={state.isOpen}
219
+ onClose={() =>
220
+ setState({
221
+ isOpen: !state.isOpen,
222
+ })
223
+ }
224
+ closeButtonLabel="Close this dialog"
225
+ label="Example Modal"
226
+ >
227
+ <React.Fragment>
228
+ <Modal.Header bordered>
229
+ <Box>
230
+ <Text as="h1" fontSize={400} fontWeight="semibold">
231
+ Assign Chatbot
232
+ </Text>
233
+ <Text as="div" fontSize={200}>
234
+ The chatbot will respond to customers from this profile.
235
+ </Text>
236
+ </Box>
237
+ <Box>
238
+ <button>dummy button 1</button>
239
+ <button>dummy button 2</button>
240
+ <Modal.CloseButton />
241
+ </Box>
242
+ </Modal.Header>
243
+ <Modal.Content>
244
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
245
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
246
+ {/* ...existing content... */}
247
+ </Modal.Content>
248
+ </React.Fragment>
249
+ </Modal>
250
+ </div>
251
+ )}
252
+ </StatefulStory>
253
+ ),
254
+ };
255
+
256
+ export const NoTitle: Story = {
257
+ render: () => (
258
+ <StatefulStory
259
+ initialState={{
260
+ isOpen: false,
261
+ }}
262
+ >
263
+ {({ setState, state }) => (
264
+ <div>
265
+ <Button
266
+ appearance="primary"
267
+ onClick={() =>
268
+ setState({
269
+ isOpen: !state.isOpen,
270
+ })
271
+ }
272
+ >
273
+ Open Modal
274
+ </Button>
275
+ <Modal
276
+ appElementSelector="#root"
277
+ isOpen={state.isOpen}
278
+ onClose={() =>
279
+ setState({
280
+ isOpen: !state.isOpen,
281
+ })
282
+ }
283
+ closeButtonLabel="Close this dialog"
284
+ label="Example Modal"
285
+ >
286
+ <React.Fragment>
287
+ <Modal.Header />
288
+ <Modal.Content>
289
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
290
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
291
+ {/* ...existing content... */}
292
+ </Modal.Content>
293
+ <Modal.Footer>
294
+ <Button appearance="primary" width={1}>
295
+ Full-Width Button
296
+ </Button>
297
+ </Modal.Footer>
298
+ </React.Fragment>
299
+ </Modal>
300
+ </div>
301
+ )}
302
+ </StatefulStory>
303
+ ),
304
+ };
305
+
306
+ export const WithForm: Story = {
307
+ render: () => (
308
+ <StatefulStory
309
+ initialState={{
310
+ isOpen: false,
311
+ }}
312
+ >
313
+ {({ setState, state }) => (
314
+ <div>
315
+ <Button
316
+ appearance="primary"
317
+ onClick={() =>
318
+ setState({
319
+ isOpen: !state.isOpen,
320
+ })
321
+ }
322
+ >
323
+ Open Modal
324
+ </Button>
325
+ <Modal
326
+ width="500px"
327
+ appElementSelector="#root"
328
+ isOpen={state.isOpen}
329
+ onClose={() =>
330
+ setState({
331
+ isOpen: !state.isOpen,
332
+ })
333
+ }
334
+ closeButtonLabel="Close this dialog"
335
+ label="Example Modal"
336
+ >
337
+ <React.Fragment>
338
+ <Modal.Header
339
+ title="Create Share Link"
340
+ subtitle="Anyone with this link will be able to view its contents."
341
+ />
342
+ <Modal.Content>
343
+ <FormField
344
+ label="Label"
345
+ helperText="This is some helpful helper text"
346
+ >
347
+ {
348
+ // is there a reason that the props are not passed to the input?
349
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
350
+ (props) => <Input name="title" id="title" />
351
+ }
352
+ </FormField>
353
+ </Modal.Content>
354
+ <Modal.Footer>
355
+ <Box display="flex" justifyContent="flex-end">
356
+ <Button appearance="primary">Create Link</Button>
357
+ </Box>
358
+ </Modal.Footer>
359
+ </React.Fragment>
360
+ </Modal>
361
+ </div>
362
+ )}
363
+ </StatefulStory>
364
+ ),
365
+ };
366
+
367
+ export const CustomBackgroundColor: Story = {
368
+ render: () => (
369
+ <StatefulStory
370
+ initialState={{
371
+ isOpen: false,
372
+ }}
373
+ >
374
+ {({ setState, state }) => (
375
+ <div>
376
+ <Button
377
+ appearance="primary"
378
+ onClick={() =>
379
+ setState({
380
+ isOpen: !state.isOpen,
381
+ })
382
+ }
383
+ >
384
+ Open Modal
385
+ </Button>
386
+ <Modal
387
+ bg="container.background.decorative.purple"
388
+ width="500px"
389
+ appElementSelector="#root"
390
+ isOpen={state.isOpen}
391
+ onClose={() =>
392
+ setState({
393
+ isOpen: !state.isOpen,
394
+ })
395
+ }
396
+ closeButtonLabel="Close this dialog"
397
+ label="Example Modal"
398
+ >
399
+ <React.Fragment>
400
+ <Modal.Header
401
+ title="Create Share Link"
402
+ subtitle="Anyone with this link will be able to view its contents."
403
+ />
404
+ <Modal.Content>
405
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
406
+ eiusmod tempor incididunt ut labore et dolore magna aliqua.
407
+ {/* ...existing content... */}
408
+ </Modal.Content>
409
+ <Modal.Footer>
410
+ <Box display="flex" justifyContent="flex-end">
411
+ <Button appearance="primary">Create Link</Button>
412
+ </Box>
413
+ </Modal.Footer>
414
+ </React.Fragment>
415
+ </Modal>
416
+ </div>
417
+ )}
418
+ </StatefulStory>
419
+ ),
420
+ };
package/src/Modal.tsx ADDED
@@ -0,0 +1,160 @@
1
+ import * as React from "react";
2
+ import { useContext } from "react";
3
+ import Box from "@sproutsocial/seeds-react-box";
4
+ import Button from "@sproutsocial/seeds-react-button";
5
+ import Icon from "@sproutsocial/seeds-react-icon";
6
+ import Text from "@sproutsocial/seeds-react-text";
7
+ import { Container, Content, Header, Footer, Body } from "./styles";
8
+ import type {
9
+ TypeModalProps,
10
+ TypeModalCloseButtonProps,
11
+ TypeModalContentProps,
12
+ TypeModalFooterProps,
13
+ TypeModalHeaderProps,
14
+ } from "./ModalTypes";
15
+
16
+ type TypeModalContext = Partial<{
17
+ onClose: () => void;
18
+ closeButtonLabel: string;
19
+ label: string;
20
+ }>;
21
+
22
+ const ModalContext = React.createContext<TypeModalContext>({});
23
+
24
+ const ModalHeader = (props: TypeModalHeaderProps) => {
25
+ const { title, subtitle, children, bordered, ...rest } = props;
26
+ return (
27
+ <Header bordered={title || subtitle || bordered} {...rest}>
28
+ {children ? (
29
+ children
30
+ ) : (
31
+ <React.Fragment>
32
+ <Box>
33
+ {title && (
34
+ <Text as="h1" fontSize={400} fontWeight="semibold">
35
+ {title}
36
+ </Text>
37
+ )}
38
+ {subtitle && (
39
+ <Text as="div" fontSize={200}>
40
+ {subtitle}
41
+ </Text>
42
+ )}
43
+ </Box>
44
+ <Box display="flex" alignItems="center" justify-content="flex-end">
45
+ <ModalCloseButton ml={400} />
46
+ </Box>
47
+ </React.Fragment>
48
+ )}
49
+ </Header>
50
+ );
51
+ };
52
+
53
+ const ModalCloseButton = (props: TypeModalCloseButtonProps) => {
54
+ const { onClose, closeButtonLabel } = useContext(ModalContext);
55
+ if (!onClose) return null;
56
+ return (
57
+ <Button onClick={onClose} {...props}>
58
+ <Icon name="x-outline" ariaLabel={closeButtonLabel} />
59
+ </Button>
60
+ );
61
+ };
62
+
63
+ const ModalFooter = (props: TypeModalFooterProps) => (
64
+ <Footer borderTop={500} borderColor="container.border.base" {...props} />
65
+ );
66
+
67
+ ModalFooter.defaultProps = {
68
+ bg: "container.background.base",
69
+ };
70
+
71
+ const ModalContent = React.forwardRef(
72
+ ({ children, ...rest }: TypeModalContentProps, ref) => {
73
+ const { label } = useContext(ModalContext);
74
+ return (
75
+ <Content data-qa-modal data-qa-label={label} ref={ref} {...rest}>
76
+ {children}
77
+ </Content>
78
+ );
79
+ }
80
+ );
81
+
82
+ /**
83
+ * The modal you want
84
+ */
85
+ const Modal = (props: TypeModalProps) => {
86
+ const {
87
+ appElementSelector,
88
+ children,
89
+ isOpen,
90
+ label,
91
+ onClose,
92
+ closeButtonLabel,
93
+ width,
94
+ zIndex,
95
+ data = {},
96
+ ...rest
97
+ } = props;
98
+
99
+ const isCloseable = Boolean(onClose);
100
+ const appElement =
101
+ appElementSelector && document
102
+ ? (document.querySelector(appElementSelector) as HTMLElement)
103
+ : undefined;
104
+
105
+ return (
106
+ <Container
107
+ appElement={appElement}
108
+ ariaHideApp={!!appElement}
109
+ isOpen={isOpen}
110
+ contentLabel={label}
111
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
112
+ onRequestClose={onClose || (() => {})}
113
+ shouldFocusAfterRender={true}
114
+ shouldCloseOnOverlayClick={isCloseable}
115
+ shouldCloseOnEsc={isCloseable}
116
+ shouldReturnFocusAfterClose={true}
117
+ closeTimeoutMS={200}
118
+ role="dialog"
119
+ width={width}
120
+ zIndex={zIndex}
121
+ data={{
122
+ "qa-modal": "",
123
+ "qa-modal-isopen": isOpen,
124
+ ...data,
125
+ }}
126
+ {...rest}
127
+ >
128
+ <React.Fragment>
129
+ <Body />
130
+
131
+ <ModalContext.Provider
132
+ value={{
133
+ onClose,
134
+ closeButtonLabel,
135
+ label,
136
+ }}
137
+ >
138
+ {children}
139
+ </ModalContext.Provider>
140
+ </React.Fragment>
141
+ </Container>
142
+ );
143
+ };
144
+
145
+ Modal.defaultProps = {
146
+ width: "800px",
147
+ zIndex: 6,
148
+ };
149
+
150
+ ModalHeader.displayName = "Modal.Header";
151
+ ModalFooter.displayName = "Modal.Footer";
152
+ ModalContent.displayName = "Modal.Content";
153
+ ModalCloseButton.displayName = "Modal.CloseButton";
154
+
155
+ Modal.Header = ModalHeader;
156
+ Modal.Footer = ModalFooter;
157
+ Modal.Content = ModalContent;
158
+ Modal.CloseButton = ModalCloseButton;
159
+
160
+ export default Modal;
@@ -0,0 +1,67 @@
1
+ import * as React from "react";
2
+ import ReactModal from "react-modal";
3
+ import type {
4
+ TypeBoxProps,
5
+ TypeContainerProps,
6
+ } from "@sproutsocial/seeds-react-box";
7
+ import type { TypeButtonProps } from "@sproutsocial/seeds-react-button";
8
+
9
+ export interface TypeModalCloseButtonProps
10
+ extends Omit<TypeButtonProps, "children"> {
11
+ children?: void | null;
12
+ }
13
+
14
+ export interface TypeModalHeaderProps extends TypeBoxProps {
15
+ title?: string;
16
+ subtitle?: string;
17
+
18
+ /** Passing children will override the default modal header */
19
+ children?: React.ReactNode;
20
+
21
+ /** If you're rendering a custom header, you can use this prop to add a bottom border */
22
+ bordered?: boolean;
23
+ }
24
+
25
+ export interface TypeModalFooterProps extends TypeBoxProps {
26
+ bg?: string;
27
+ children: React.ReactNode;
28
+ }
29
+
30
+ export interface TypeModalContentProps extends TypeBoxProps {
31
+ children?: React.ReactNode;
32
+ }
33
+
34
+ export interface TypeModalProps
35
+ extends TypeContainerProps,
36
+ // @ts-notes - onClose is an alias for onRequestClose so we don't need to include it here
37
+ Omit<ReactModal.Props, keyof TypeContainerProps | "onRequestClose"> {
38
+ /** section of app to aria hide for the modal */
39
+ appElementSelector?: string;
40
+
41
+ /** trigger to open or close the modal */
42
+ isOpen: boolean;
43
+
44
+ /** label for screen readers to announce the modal */
45
+ label: string;
46
+
47
+ /** body content of the modal */
48
+ children: React.ReactNode;
49
+
50
+ /** callback for close */
51
+ onClose?: () => void;
52
+
53
+ /** aria-label for modal X */
54
+ closeButtonLabel: string;
55
+
56
+ /** Controls the z-index CSS property */
57
+ zIndex?: number;
58
+
59
+ /** The max width of the modal container */
60
+ width?: string | number;
61
+
62
+ /**
63
+ * Custom attributes to be added to the modals container
64
+ * Each key will be prepended with "data-" when rendered in the DOM
65
+ */
66
+ data?: Record<string, string | boolean | number>;
67
+ }