@purpurds/modal 3.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,304 @@
1
+ import React, {
2
+ ForwardedRef,
3
+ forwardRef,
4
+ ReactNode,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import * as RadixDialog from "@radix-ui/react-dialog";
11
+ import { Button, ButtonVariant } from "@purpurds/button";
12
+ import { Heading } from "@purpurds/heading";
13
+ import { IconClose } from "@purpurds/icon";
14
+ import { Paragraph } from "@purpurds/paragraph";
15
+ import { VisuallyHidden } from "@purpurds/visually-hidden";
16
+ import { purpurBreakpointMd } from "@purpurds/tokens";
17
+ import c from "classnames/bind";
18
+
19
+ import styles from "./modal-content.module.scss";
20
+
21
+ export const primaryActionVariants = ["primary", "expressive", "destructive"] as const;
22
+
23
+ export type PrimaryActionVariant = (typeof primaryActionVariants)[number];
24
+
25
+ export type DefaultProps = {
26
+ ["data-testid"]?: string;
27
+ /**
28
+ * An array of actions to be displayed at the bottom of the modal in the form of buttons. A maximum of 3 buttons will be rendered,
29
+ * */
30
+ actions?: Array<{ label: string; onClick: () => void }>;
31
+ children: ReactNode;
32
+ className?: string;
33
+ /**
34
+ * An optional accessible description to be announced when the dialog is opened.
35
+ * */
36
+ description?: string;
37
+ /**
38
+ * Prevents the modal from closing on click outside if set to `true`.
39
+ * */
40
+ disableCloseOnClickOutside?: boolean;
41
+ /**
42
+ * Visually hides the description if set to `true`.
43
+ * */
44
+ hideDescription?: boolean;
45
+ /**
46
+ * An optional image to be displayed at the top of the modal.
47
+ * */
48
+ image?: ReactNode;
49
+ /**
50
+ * The variant for the primary action button.
51
+ * */
52
+ primaryActionVariant?: PrimaryActionVariant;
53
+ /**
54
+ * Determines whether the button group should be fixed at the bottom or scroll with the content.
55
+ * */
56
+ stickyButtons?: boolean;
57
+ /**
58
+ * An accessible title to be announced when the dialog is opened.
59
+ * */
60
+ title: string;
61
+ };
62
+
63
+ type NoCloseButtonProps = {
64
+ /**
65
+ * An accessible label for the close button.
66
+ * */
67
+ closeButtonAllyLabel?: never;
68
+ /**
69
+ * Shows the close button if set to `true`. Must be used in conjunction with `closeButtonAllyLabel`.
70
+ * */
71
+ showCloseButton?: false;
72
+ };
73
+
74
+ type CloseButtonProps = {
75
+ /**
76
+ * An accessible label for the close button.
77
+ * */
78
+ closeButtonAllyLabel: string;
79
+ /**
80
+ * Shows the close button if set to `true`. Must be used in conjunction with `closeButtonAllyLabel`.
81
+ * */
82
+ showCloseButton: true;
83
+ };
84
+
85
+ export type ModalContentProps = DefaultProps & (CloseButtonProps | NoCloseButtonProps);
86
+
87
+ const hasCloseButtonProps = (
88
+ props: CloseButtonProps | NoCloseButtonProps
89
+ ): props is CloseButtonProps => (props as CloseButtonProps).showCloseButton === true;
90
+
91
+ const cx = c.bind(styles);
92
+ const rootClassName = "purpur-modal-content";
93
+
94
+ export const ModalContent = forwardRef(
95
+ (
96
+ {
97
+ ["data-testid"]: dataTestId,
98
+ actions,
99
+ children,
100
+ className,
101
+ description,
102
+ disableCloseOnClickOutside,
103
+ hideDescription,
104
+ image,
105
+ primaryActionVariant = "primary",
106
+ stickyButtons = true,
107
+ title,
108
+ ...props
109
+ }: ModalContentProps,
110
+ ref: ForwardedRef<HTMLDivElement>
111
+ ) => {
112
+ const { closeButtonAllyLabel, showCloseButton, ...rest } = props;
113
+ const [contentOverflow, setContentOverflow] = useState(false);
114
+ const wrapperRef = useRef<HTMLDivElement>(null);
115
+ const wrapperInnerRef = useRef<HTMLDivElement>(null);
116
+ const bodyWrapperRef = useRef<HTMLDivElement>(null);
117
+ const bodyInnerRef = useRef<HTMLDivElement>(null);
118
+
119
+ const classes = cx([
120
+ rootClassName,
121
+ { [`${rootClassName}--with-image`]: !!image },
122
+ { [`${rootClassName}--overflow`]: contentOverflow },
123
+ { [`${rootClassName}--sticky-footer`]: stickyButtons },
124
+ className,
125
+ ]);
126
+
127
+ const getTestId = (id: string) => (dataTestId ? `${dataTestId} ${id}` : undefined);
128
+
129
+ const handlePointerDownOutside = (event: CustomEvent<{ originalEvent: PointerEvent }>) => {
130
+ if (disableCloseOnClickOutside) {
131
+ event.preventDefault();
132
+ }
133
+ };
134
+
135
+ const handleContentOverflow = useCallback(() => {
136
+ if (
137
+ !wrapperRef.current ||
138
+ !wrapperInnerRef.current ||
139
+ !bodyWrapperRef.current ||
140
+ !bodyInnerRef.current
141
+ ) {
142
+ return;
143
+ }
144
+
145
+ const isMobile = window.innerWidth < parseInt(purpurBreakpointMd);
146
+
147
+ const computedStyle = window.getComputedStyle(
148
+ isMobile ? wrapperRef.current : bodyWrapperRef.current,
149
+ null
150
+ );
151
+ const wrapperHeight = parseFloat(computedStyle.getPropertyValue("height"));
152
+ const innerHeight = isMobile
153
+ ? wrapperInnerRef.current.offsetHeight
154
+ : bodyInnerRef.current.offsetHeight;
155
+
156
+ setContentOverflow(wrapperHeight < innerHeight);
157
+ }, []);
158
+
159
+ const handleEscapeKeyDown = (event: KeyboardEvent) => {
160
+ if (disableCloseOnClickOutside && !showCloseButton) {
161
+ event.preventDefault();
162
+ }
163
+ };
164
+
165
+ useEffect(() => {
166
+ window.addEventListener("resize", handleContentOverflow);
167
+
168
+ return () => {
169
+ window.removeEventListener("resize", handleContentOverflow);
170
+ };
171
+ }, []);
172
+
173
+ return (
174
+ <RadixDialog.Portal>
175
+ <RadixDialog.Overlay className={cx(`${rootClassName}__overlay`)} />
176
+ <RadixDialog.Content
177
+ {...rest}
178
+ className={classes}
179
+ data-testid={dataTestId}
180
+ ref={ref}
181
+ {...(!description && { ["aria-describedby"]: undefined })}
182
+ onPointerDownOutside={handlePointerDownOutside}
183
+ onOpenAutoFocus={handleContentOverflow}
184
+ onEscapeKeyDown={handleEscapeKeyDown}
185
+ >
186
+ <div ref={wrapperRef} className={cx(`${rootClassName}__wrapper`)}>
187
+ {hasCloseButtonProps(props) && closeButtonAllyLabel && image && (
188
+ <CloseButton
189
+ allyLabel={closeButtonAllyLabel}
190
+ hasImage
191
+ data-testid={getTestId("close-button")}
192
+ />
193
+ )}
194
+ <div ref={wrapperInnerRef} className={cx(`${rootClassName}__wrapper-inner`)}>
195
+ <div className={cx(`${rootClassName}__header`)}>
196
+ {hasCloseButtonProps(props) && closeButtonAllyLabel && !image && (
197
+ <CloseButton
198
+ allyLabel={closeButtonAllyLabel}
199
+ data-testid={getTestId("close-button")}
200
+ />
201
+ )}
202
+ <RadixDialog.Title
203
+ asChild
204
+ className={cx(`${rootClassName}__title`)}
205
+ data-testid={getTestId("title")}
206
+ >
207
+ <Heading tag="h2" variant="title-200">
208
+ {title}
209
+ </Heading>
210
+ </RadixDialog.Title>
211
+ </div>
212
+ {image && (
213
+ <div
214
+ className={cx(`${rootClassName}__image-wrapper`)}
215
+ data-testid={getTestId("image")}
216
+ >
217
+ {image}
218
+ </div>
219
+ )}
220
+ <div ref={bodyWrapperRef} className={cx(`${rootClassName}__body`)}>
221
+ <div ref={bodyInnerRef} className={cx(`${rootClassName}__body-inner`)}>
222
+ {description && (
223
+ <>
224
+ {hideDescription ? (
225
+ <VisuallyHidden asChild>
226
+ <RadixDialog.Description data-testid={getTestId("description")}>
227
+ {description}
228
+ </RadixDialog.Description>
229
+ </VisuallyHidden>
230
+ ) : (
231
+ <RadixDialog.Description
232
+ asChild
233
+ className={cx(`${rootClassName}__description`)}
234
+ data-testid={getTestId("description")}
235
+ >
236
+ <Paragraph variant="paragraph-100">{description}</Paragraph>
237
+ </RadixDialog.Description>
238
+ )}
239
+ </>
240
+ )}
241
+ <div>{children}</div>
242
+ {!stickyButtons && (
243
+ <ModalActions actions={actions} primaryActionVariant={primaryActionVariant} />
244
+ )}
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ {stickyButtons && (
250
+ <ModalActions actions={actions} primaryActionVariant={primaryActionVariant} />
251
+ )}
252
+ </RadixDialog.Content>
253
+ </RadixDialog.Portal>
254
+ );
255
+ }
256
+ );
257
+
258
+ const CloseButton = ({
259
+ allyLabel,
260
+ hasImage,
261
+ ["data-testid"]: dataTestId,
262
+ }: {
263
+ allyLabel: string;
264
+ hasImage?: boolean;
265
+ ["data-testid"]?: string;
266
+ }) => (
267
+ <RadixDialog.Close asChild>
268
+ <Button
269
+ variant={hasImage ? "primary-negative" : "tertiary-purple"}
270
+ size="sm"
271
+ iconOnly
272
+ aria-label={allyLabel}
273
+ className={cx(`${rootClassName}__close-button`)}
274
+ data-testid={dataTestId}
275
+ >
276
+ <IconClose />
277
+ </Button>
278
+ </RadixDialog.Close>
279
+ );
280
+
281
+ export const MAX_NUMBER_OF_ACTIONS = 3;
282
+
283
+ export const ModalActions = ({
284
+ actions,
285
+ primaryActionVariant,
286
+ }: Pick<ModalContentProps, "actions" | "primaryActionVariant">) => {
287
+ const buttonVariants = [primaryActionVariant, "secondary", "text"];
288
+
289
+ return actions && actions.length > 0 ? (
290
+ <div className={cx(`${rootClassName}__actions`)} data-testid="modal actions">
291
+ {actions.slice(0, MAX_NUMBER_OF_ACTIONS).map(({ label, onClick }, index) => (
292
+ <Button
293
+ key={label}
294
+ data-testid="modal actions button"
295
+ variant={(buttonVariants[index] as ButtonVariant) || ""}
296
+ onClick={onClick}
297
+ className={cx(`${rootClassName}__button`)}
298
+ >
299
+ {label}
300
+ </Button>
301
+ ))}
302
+ </div>
303
+ ) : null;
304
+ };
@@ -0,0 +1,27 @@
1
+ import React, { ForwardedRef, forwardRef, ReactNode } from "react";
2
+ import * as RadixDialog from "@radix-ui/react-dialog";
3
+
4
+ export type ModalTriggerProps = {
5
+ ["data-testid"]?: string;
6
+ children: ReactNode;
7
+ className?: string;
8
+ };
9
+
10
+ export const ModalTrigger = forwardRef(
11
+ (
12
+ { ["data-testid"]: dataTestId, children, className, ...props }: ModalTriggerProps,
13
+ ref: ForwardedRef<HTMLButtonElement>
14
+ ) => {
15
+ return (
16
+ <RadixDialog.Trigger
17
+ asChild
18
+ ref={ref}
19
+ data-testid={dataTestId}
20
+ className={className}
21
+ {...props}
22
+ >
23
+ {children}
24
+ </RadixDialog.Trigger>
25
+ );
26
+ }
27
+ );
@@ -0,0 +1,124 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Button } from "@purpurds/button";
4
+ import { Paragraph } from "@purpurds/paragraph";
5
+ import { TextSpacing } from "@purpurds/text-spacing";
6
+
7
+ import { Modal } from "./modal";
8
+
9
+ import "@purpurds/button/styles";
10
+ import "@purpurds/icon/styles";
11
+ import "@purpurds/text-spacing/styles";
12
+
13
+ const meta: Meta<typeof Modal> = {
14
+ title: "Components/Modal",
15
+ component: Modal,
16
+ };
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof Modal>;
20
+
21
+ export const Showcase: Story = {
22
+ parameters: {
23
+ design: [
24
+ {
25
+ name: "Modal",
26
+ type: "figma",
27
+ url: "https://www.figma.com/file/TggtRkYyKpwgKTU0LxuYFN/Modal-redesign?type=design&node-id=104-731&mode=design&t=sm44NPLlG3tjpFQb-0",
28
+ },
29
+ ],
30
+ },
31
+ render: () => {
32
+ const cookieActions = [
33
+ { label: "I accept cookies", onClick: () => {} },
34
+ { label: "Settings", onClick: () => {} },
35
+ { label: "Reject all", onClick: () => {} },
36
+ ];
37
+
38
+ const privacyPolicyActions = [
39
+ { label: "Agree", onClick: () => {} },
40
+ { label: "Disagree", onClick: () => {} },
41
+ ];
42
+
43
+ const subscriptionActions = [
44
+ { label: "End main subscription", onClick: () => {} },
45
+ { label: "Cancel", onClick: () => {} },
46
+ ];
47
+
48
+ return (
49
+ <div style={{ display: "flex", gap: "var(--purpur-spacing-200)" }}>
50
+ <Modal>
51
+ <Modal.Trigger>
52
+ <Button variant="primary">Open cookie consent</Button>
53
+ </Modal.Trigger>
54
+ <Modal.Content title="We use cookies" actions={cookieActions}>
55
+ <Paragraph variant="paragraph-100">
56
+ By clicking "I accept cookies" you consent to the storage of cookies on your device to
57
+ improve website navigation, analyze website usage and assist in our marketing efforts.
58
+ </Paragraph>
59
+ </Modal.Content>
60
+ </Modal>
61
+
62
+ <Modal>
63
+ <Modal.Trigger>
64
+ <Button variant="secondary">Open privacy policy</Button>
65
+ </Modal.Trigger>
66
+ <Modal.Content title="Privacy policy for Telia Sverige AB" actions={privacyPolicyActions}>
67
+ <TextSpacing>
68
+ <Paragraph variant="paragraph-100">
69
+ We are Telia Sverige AB (org. nr 556430-0142), (hereafter ‘Telia’). In this Privacy
70
+ policy we describe how we, as the controller, process and protect your personal data
71
+ (hereafter ‘data’). Telia recognizes that the protection of your personal data is
72
+ extremely important. Therefore, we protect the privacy of every data subject with
73
+ the utmost responsibility and care. When processing personal data, we comply with
74
+ the General Data Protection Regulation (GDPR), the Data Protection Act 2018:2181 ,
75
+ The Electronic Communications Act 2022:
76
+ </Paragraph>
77
+ <Paragraph variant="paragraph-100">
78
+ This Privacy policy applies to the processing of personal data of individuals,
79
+ regardless of whether you are a consumer or business customer. In addition, Telia
80
+ may have service-specific privacy policys, which describe the processing of personal
81
+ data in the context of a specific service. These can be found along with the
82
+ specific services that we provide.
83
+ </Paragraph>
84
+ <Paragraph variant="paragraph-100">This Privacy policy sets out:</Paragraph>
85
+ <ul>
86
+ <li>how we collect personal data</li>
87
+ <li>what personal data we process</li>
88
+ <li>for what purposes</li>
89
+ <li>based on which legal grounds and for how long we process personal data</li>
90
+ <li>how we protect and safeguard personal data</li>
91
+ <li>to whom we disclose personal data</li>
92
+ <li>
93
+ what rights you have regarding the processing of your personal data and how you
94
+ can execute these rights.
95
+ </li>
96
+ </ul>
97
+ <Paragraph variant="paragraph-100">
98
+ This Privacy policy does not apply to the processing of personal data processed by
99
+ other companies when you are using their services or websites, even if they were
100
+ accessed through Telia's communications network or services.
101
+ </Paragraph>
102
+ </TextSpacing>
103
+ </Modal.Content>
104
+ </Modal>
105
+
106
+ <Modal>
107
+ <Modal.Trigger>
108
+ <Button variant="destructive">End main subscription</Button>
109
+ </Modal.Trigger>
110
+ <Modal.Content
111
+ title="Do you want to cancel the main subscription?"
112
+ actions={subscriptionActions}
113
+ primaryActionVariant="destructive"
114
+ >
115
+ <Paragraph variant="paragraph-100">
116
+ Upon termination of the main subscription, all related data sims and e-sims are
117
+ terminated. The notice period may vary.
118
+ </Paragraph>
119
+ </Modal.Content>
120
+ </Modal>
121
+ </div>
122
+ );
123
+ },
124
+ };
@@ -0,0 +1,74 @@
1
+ import React from "react";
2
+ import * as matchers from "@testing-library/jest-dom/matchers";
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { Modal } from "./modal";
7
+
8
+ expect.extend(matchers);
9
+
10
+ describe("Modal", () => {
11
+ afterEach(() => {
12
+ cleanup();
13
+ });
14
+
15
+ it("should render Trigger and Content as children", () => {
16
+ render(
17
+ <Modal open>
18
+ <Modal.Trigger data-testid="modal trigger">
19
+ <button type="button">Open</button>
20
+ </Modal.Trigger>
21
+ <Modal.Content data-testid="modal content" title="Title">
22
+ Some content
23
+ </Modal.Content>
24
+ </Modal>
25
+ );
26
+
27
+ expect(screen.getByTestId("modal trigger")).toBeInTheDocument();
28
+ expect(screen.getByTestId("modal content")).toBeInTheDocument();
29
+ });
30
+
31
+ it("should only render Content when modal is open", () => {
32
+ render(
33
+ <Modal>
34
+ <Modal.Trigger data-testid="modal trigger">
35
+ <button type="button">Open</button>
36
+ </Modal.Trigger>
37
+ <Modal.Content data-testid="modal content" title="Title">
38
+ Some content
39
+ </Modal.Content>
40
+ </Modal>
41
+ );
42
+
43
+ const trigger = screen.getByTestId("modal trigger");
44
+
45
+ expect(screen.queryByTestId("modal content")).not.toBeInTheDocument();
46
+
47
+ fireEvent.click(trigger);
48
+
49
+ expect(screen.getByTestId("modal content")).toBeInTheDocument();
50
+ });
51
+
52
+ it("should trigger `onOpenChange` callback on trigger click", () => {
53
+ const onOpenChangeMock = vi.fn();
54
+
55
+ render(
56
+ <Modal open={false} onOpenChange={onOpenChangeMock}>
57
+ <Modal.Trigger data-testid="modal trigger">
58
+ <button type="button">Open</button>
59
+ </Modal.Trigger>
60
+ <Modal.Content data-testid="modal content" title="Title">
61
+ Some content
62
+ </Modal.Content>
63
+ </Modal>
64
+ );
65
+
66
+ const trigger = screen.getByTestId("modal trigger");
67
+
68
+ expect(onOpenChangeMock).not.toHaveBeenCalled();
69
+
70
+ fireEvent.click(trigger);
71
+
72
+ expect(onOpenChangeMock).toHaveBeenCalledWith(true);
73
+ });
74
+ });
package/src/modal.tsx ADDED
@@ -0,0 +1,36 @@
1
+ import React, { ReactElement } from "react";
2
+ import * as RadixDialog from "@radix-ui/react-dialog";
3
+
4
+ import { ModalContent } from "./modal-content";
5
+ import { ModalTrigger } from "./modal-trigger";
6
+
7
+ export type ModalProps = {
8
+ ["data-testid"]?: string;
9
+ children: Array<ReactElement<typeof ModalTrigger | typeof ModalContent>>;
10
+ /**
11
+ * Event handler called when the open state of the dialog changes.
12
+ * */
13
+ onOpenChange?: (value: boolean) => void;
14
+ /**
15
+ * The controlled open state of the dialog. Must be used in conjunction with `onOpenChange`.
16
+ * */
17
+ open?: boolean;
18
+ };
19
+
20
+ export const Modal = ({
21
+ ["data-testid"]: dataTestId,
22
+ children,
23
+ open,
24
+ onOpenChange,
25
+ }: ModalProps) => {
26
+ return (
27
+ <RadixDialog.Root open={open} onOpenChange={onOpenChange} data-testid={dataTestId}>
28
+ {children}
29
+ </RadixDialog.Root>
30
+ );
31
+ };
32
+
33
+ Modal.Trigger = ModalTrigger;
34
+ Modal.Content = ModalContent;
35
+
36
+ Modal.displayName = "Modal";