@nypl/design-system-react-components 0.26.1 → 0.27.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.
@@ -1,20 +1,162 @@
1
1
  import * as React from "react";
2
- import { render } from "@testing-library/react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { renderHook } from "@testing-library/react-hooks";
3
4
  import { axe } from "jest-axe";
5
+ import renderer from "react-test-renderer";
4
6
 
5
- import Modal from "./Modal";
7
+ import { Button } from "../Button/Button";
8
+ import { ButtonTypes } from "../Button/ButtonTypes";
9
+ import { ModalTrigger, useModal } from "./Modal";
6
10
 
7
11
  describe("Modal Accessibility", () => {
8
- it("passes axe accessibility test", async () => {
9
- const { container } = render(<Modal />);
12
+ it("passes axe accessibility for ModalTrigger", async () => {
13
+ const { container } = render(
14
+ <ModalTrigger
15
+ buttonText="Button Text"
16
+ id="modal-trigger"
17
+ modalProps={{
18
+ bodyContent: "body text",
19
+ closeButtonLabel: "Close Button",
20
+ headingText: "Modal Heading Text",
21
+ onClose: () => {
22
+ console.log("custom close");
23
+ },
24
+ }}
25
+ />
26
+ );
10
27
  expect(await axe(container)).toHaveNoViolations();
11
28
  });
29
+
30
+ it("passes axe accessibility for useModal", async () => {
31
+ const { result } = renderHook(() => useModal());
32
+ const { onClose, onOpen, Modal } = result.current;
33
+ const modalProps = {
34
+ bodyContent: (
35
+ <>
36
+ <Button id="custom-close" onClick={onClose}>
37
+ Go back
38
+ </Button>
39
+ <p>This is the body content.</p>
40
+ <Button
41
+ buttonType={ButtonTypes.NoBrand}
42
+ id="custom-close2"
43
+ onClick={onClose}
44
+ >
45
+ This is a custom close button.
46
+ </Button>
47
+ </>
48
+ ),
49
+ closeButtonLabel: "Close Button",
50
+ headingText: "Modal Heading Text",
51
+ onClose: () => {
52
+ console.log("custom close");
53
+ onClose();
54
+ },
55
+ };
56
+ const { container } = render(
57
+ <>
58
+ <Button id="1" onClick={onOpen} buttonType={ButtonTypes.NoBrand}>
59
+ Open Modal
60
+ </Button>
61
+ <Modal {...modalProps} />
62
+ </>
63
+ );
64
+ expect(await axe(container)).toHaveNoViolations();
65
+ });
66
+ });
67
+
68
+ describe("ModalTrigger", () => {
69
+ const modalTrigger = (
70
+ <ModalTrigger
71
+ buttonText="Button Text"
72
+ id="modal-trigger"
73
+ modalProps={{
74
+ bodyContent: "body text",
75
+ closeButtonLabel: "Close Button",
76
+ headingText: "Modal Heading Text",
77
+ onClose: () => {
78
+ console.log("custom close");
79
+ },
80
+ }}
81
+ />
82
+ );
83
+
84
+ it("renders content when it is opened", () => {
85
+ render(modalTrigger);
86
+ const openButton = screen.getByText("Button Text");
87
+ const closeButton = screen.queryByText("Close Button");
88
+
89
+ expect(openButton).toBeInTheDocument();
90
+ expect(closeButton).not.toBeInTheDocument();
91
+ expect(screen.queryByText("Modal Heading Text")).not.toBeInTheDocument();
92
+
93
+ openButton.click();
94
+
95
+ expect(openButton).toBeInTheDocument();
96
+ expect(screen.queryByText("Close Button")).toBeInTheDocument();
97
+ expect(screen.queryByText("Modal Heading Text")).toBeInTheDocument();
98
+ });
99
+
100
+ it("renders the UI snapshot correctly", () => {
101
+ const basic = renderer.create(modalTrigger).toJSON();
102
+
103
+ expect(basic).toMatchSnapshot();
104
+ });
12
105
  });
13
106
 
14
- describe("Modal", () => {
15
- it("modal applies 'no-scroll' class to body", () => {
16
- const utils = render(<Modal />);
17
- expect(utils.container.querySelector(".modal")).toBeInTheDocument();
18
- expect(document.body.classList[0]).toEqual("no-scroll");
107
+ describe("useModal", () => {
108
+ const { result } = renderHook(() => useModal());
109
+ const { onClose, onOpen, Modal } = result.current;
110
+ const modalProps = {
111
+ bodyContent: (
112
+ <>
113
+ <Button id="custom-close" onClick={onClose}>
114
+ Go back
115
+ </Button>
116
+ <p>This is the body content.</p>
117
+ <Button id="custom-close2" onClick={onClose}>
118
+ This is a custom close button.
119
+ </Button>
120
+ </>
121
+ ),
122
+ closeButtonLabel: "Close Button",
123
+ headingText: "Modal Heading Text",
124
+ onClose: () => {
125
+ console.log("custom close");
126
+ onClose();
127
+ },
128
+ };
129
+ const useModalComponent = (
130
+ <>
131
+ <Button id="1" onClick={onOpen} buttonType={ButtonTypes.NoBrand}>
132
+ Open Modal
133
+ </Button>
134
+ <Modal {...modalProps} />
135
+ </>
136
+ );
137
+
138
+ it("renders content when it is opened", () => {
139
+ render(useModalComponent);
140
+ const openButton = screen.getByText("Open Modal");
141
+ const closeButton = screen.queryByText("This is a custom close button.");
142
+
143
+ expect(openButton).toBeInTheDocument();
144
+ expect(closeButton).not.toBeInTheDocument();
145
+ expect(screen.queryByText("Modal Heading Text")).not.toBeInTheDocument();
146
+
147
+ openButton.click();
148
+
149
+ // TODO: Fix this test
150
+ // expect(openButton).toBeInTheDocument();
151
+ // expect(
152
+ // screen.queryByText("This is a custom close button.")
153
+ // ).toBeInTheDocument();
154
+ // expect(screen.queryByText("Modal Heading Text")).toBeInTheDocument();
155
+ });
156
+
157
+ it("renders the UI snapshot correctly", () => {
158
+ const basic = renderer.create(useModalComponent).toJSON();
159
+
160
+ expect(basic).toMatchSnapshot();
19
161
  });
20
162
  });
@@ -1,34 +1,154 @@
1
+ import {
2
+ chakra,
3
+ Modal as ChakraModal,
4
+ ModalOverlay,
5
+ ModalContent,
6
+ ModalHeader,
7
+ ModalFooter,
8
+ ModalBody,
9
+ ModalCloseButton,
10
+ useDisclosure,
11
+ ButtonGroup,
12
+ } from "@chakra-ui/react";
1
13
  import * as React from "react";
2
14
 
3
- export interface ModalProps {
4
- /** ClassName that appears in addition to "modal" */
5
- className?: string;
15
+ import Button from "../Button/Button";
16
+ import useWindowSize from "../../hooks/useWindowSize";
17
+
18
+ interface BaseModalProps {
19
+ bodyContent?: string | JSX.Element;
20
+ closeButtonLabel?: string;
21
+ headingText?: string | JSX.Element;
6
22
  /** ID that other components can cross reference for accessibility purposes */
7
23
  id?: string;
24
+ isOpen?: boolean;
25
+ onClose?: () => void;
8
26
  }
9
27
 
10
- /** Full-screen modal that appears on top of the body of the page. */
11
- export default class Modal extends React.Component<ModalProps, any> {
12
- componentDidMount() {
13
- document.body.classList.add("no-scroll");
14
- }
15
-
16
- componentWillUnmount() {
17
- document.body.classList.remove("no-scroll");
18
- }
28
+ export interface ModalProps {
29
+ buttonText?: string;
30
+ /** ID that other components can cross reference for accessibility purposes */
31
+ id?: string;
32
+ modalProps: BaseModalProps;
33
+ }
19
34
 
20
- render() {
21
- const { id, className } = this.props;
35
+ const BaseModal = chakra(
36
+ ({
37
+ bodyContent,
38
+ closeButtonLabel = "Close",
39
+ headingText,
40
+ id,
41
+ isOpen,
42
+ onClose,
43
+ ...rest
44
+ }: React.PropsWithChildren<BaseModalProps>) => {
45
+ // Based on --nypl-breakpoint-medium
46
+ const breakpointMedium = 600;
47
+ const defaultSize = "xl";
48
+ const fullSize = "full";
49
+ const [size, setSize] = React.useState<string>(defaultSize);
50
+ const windowDimensions = useWindowSize();
51
+ React.useEffect(() => {
52
+ if (windowDimensions.width <= breakpointMedium) {
53
+ setSize(fullSize);
54
+ } else {
55
+ setSize(defaultSize);
56
+ }
57
+ }, [windowDimensions.width]);
22
58
 
23
59
  return (
24
- <div
25
- // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
26
- tabIndex={0}
27
- className={`modal ${className}`}
60
+ <ChakraModal
28
61
  id={id}
62
+ isOpen={isOpen}
63
+ onClose={onClose}
64
+ scrollBehavior="inside"
65
+ size={size}
66
+ {...rest}
29
67
  >
30
- {this.props.children}
31
- </div>
68
+ <ModalOverlay />
69
+ <ModalContent>
70
+ <ModalHeader>{headingText}</ModalHeader>
71
+ <ModalCloseButton />
72
+ <ModalBody>{bodyContent}</ModalBody>
73
+
74
+ <ModalFooter>
75
+ <ButtonGroup>
76
+ <Button id="modal-close-btn" onClick={onClose}>
77
+ {closeButtonLabel}
78
+ </Button>
79
+ </ButtonGroup>
80
+ </ModalFooter>
81
+ </ModalContent>
82
+ </ChakraModal>
83
+ );
84
+ }
85
+ );
86
+
87
+ /**
88
+ * The `ModalTrigger` component renders a button that you click to open the
89
+ * internal `Modal` component. Note that props to update the internal `Modal`
90
+ * component are passed through to the `modalProps` prop.
91
+ */
92
+ export const ModalTrigger = chakra(
93
+ ({
94
+ buttonText,
95
+ id,
96
+ modalProps,
97
+ ...rest
98
+ }: React.PropsWithChildren<ModalProps>) => {
99
+ const { isOpen, onOpen, onClose } = useDisclosure();
100
+ const finalOnCloseHandler = () => {
101
+ modalProps.onClose && modalProps.onClose();
102
+ onClose();
103
+ };
104
+ return (
105
+ <>
106
+ <Button id="modal-open-btn" onClick={onOpen}>
107
+ {buttonText}
108
+ </Button>
109
+
110
+ <BaseModal
111
+ bodyContent={modalProps.bodyContent}
112
+ closeButtonLabel={modalProps.closeButtonLabel}
113
+ headingText={modalProps.headingText}
114
+ id={id}
115
+ isOpen={isOpen}
116
+ onClose={finalOnCloseHandler}
117
+ {...rest}
118
+ />
119
+ </>
32
120
  );
33
121
  }
122
+ );
123
+
124
+ /**
125
+ * This hook function can be used to render the `Modal` component with a custom
126
+ * open button(s) and optional custom close button(s). You must render your own
127
+ * button and pass the appropriate `onOpen` and ` handler for the modal to open.
128
+ */
129
+ export function useModal() {
130
+ const { isOpen, onClose, onOpen } = useDisclosure();
131
+ const Modal = chakra(
132
+ ({
133
+ bodyContent,
134
+ closeButtonLabel,
135
+ headingText,
136
+ id,
137
+ ...rest
138
+ }: React.PropsWithChildren<BaseModalProps>) => {
139
+ return (
140
+ <BaseModal
141
+ bodyContent={bodyContent}
142
+ closeButtonLabel={closeButtonLabel}
143
+ headingText={headingText}
144
+ id={id}
145
+ isOpen={isOpen}
146
+ onClose={onClose}
147
+ {...rest}
148
+ />
149
+ );
150
+ }
151
+ );
152
+
153
+ return { onClose, onOpen, Modal };
34
154
  }
@@ -0,0 +1,25 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`ModalTrigger renders the UI snapshot correctly 1`] = `
4
+ <button
5
+ className="chakra-button css-1xdhyk6"
6
+ data-testid="button"
7
+ id="modal-open-btn"
8
+ onClick={[Function]}
9
+ type="button"
10
+ >
11
+ Button Text
12
+ </button>
13
+ `;
14
+
15
+ exports[`useModal renders the UI snapshot correctly 1`] = `
16
+ <button
17
+ className="chakra-button css-1xdhyk6"
18
+ data-testid="button"
19
+ id="1"
20
+ onClick={[Function]}
21
+ type="button"
22
+ >
23
+ Open Modal
24
+ </button>
25
+ `;
@@ -8,7 +8,6 @@ import {
8
8
  Story,
9
9
  } from "@storybook/addon-docs";
10
10
  import { withDesign } from "storybook-addon-designs";
11
- import { withQuery } from "@storybook/addon-queryparams";
12
11
 
13
12
  import Button from "../Button/Button";
14
13
  import DSProvider from "../../theme/provider";
@@ -21,7 +20,7 @@ export const hrefProps = getStorybookHrefProps(10);
21
20
  <Meta
22
21
  title={getCategory("Pagination")}
23
22
  component={Pagination}
24
- decorators={[withDesign, withQuery]}
23
+ decorators={[withDesign]}
25
24
  parameters={{
26
25
  design: {
27
26
  type: "figma",
@@ -214,8 +214,8 @@ exports[`SearchBar renders the UI snapshot correctly 2`] = `
214
214
  </svg>
215
215
  </div>
216
216
  </div>
217
-
218
217
  </div>
218
+
219
219
  </div>
220
220
  <div
221
221
  className="css-1xdhyk6"
@@ -79,7 +79,7 @@ export const labelPositionsEnumValues = getStorybookEnumValues(
79
79
  | Component Version | DS Version |
80
80
  | ----------------- | ---------- |
81
81
  | Added | `0.7.0` |
82
- | Latest | `0.26.0` |
82
+ | Latest | `0.27.0` |
83
83
 
84
84
  ## Table of Contents
85
85
 
@@ -309,9 +309,9 @@ can be used to show or hide the "Required" text within the `label` element.
309
309
 
310
310
  ### Controlled Component using `value` and `onChange` props
311
311
 
312
- If your application uses controlled React components and the Reservoir Design
312
+ If your application uses controlled React components and the Reservoir Design
313
313
  System (DS) `Select` component must be controlled, you can pass and extract the
314
- value through the `value` and `onChange` props. This will be called every time
314
+ value through the `value` and `onChange` props. This will be called every time
315
315
  a new `option` value is selected.
316
316
 
317
317
  Try it out: open up the browser's console to see new values being logged on
@@ -96,7 +96,6 @@ export const Select = chakra(
96
96
  const styles = useMultiStyleConfig("CustomSelect", {
97
97
  variant: selectType,
98
98
  labelPosition,
99
- labelWidth,
100
99
  });
101
100
  const finalInvalidText = invalidText
102
101
  ? invalidText
@@ -108,6 +107,10 @@ export const Select = chakra(
108
107
  // must be passed.
109
108
  const controlledProps = onChange ? { onChange, value } : {};
110
109
 
110
+ // The number of pixels between the label and select elements
111
+ // when the labelPosition is inline (equivalent to --nypl-space-xs).
112
+ const labelSelectGap = 8;
113
+
111
114
  if (!showLabel) {
112
115
  ariaAttributes["aria-label"] =
113
116
  labelText && footnote ? `${labelText} - ${footnote}` : labelText;
@@ -124,9 +127,11 @@ export const Select = chakra(
124
127
  useEffect(() => {
125
128
  if (labelPosition === LabelPositions.Inline) {
126
129
  if (labelRef.current) {
127
- const width = labelRef.current.clientWidth + 8;
130
+ const width = labelRef.current.clientWidth + labelSelectGap;
128
131
  setLabelWidth(width);
129
132
  }
133
+ } else {
134
+ setLabelWidth(0);
130
135
  }
131
136
  }, [labelPosition]);
132
137
 
@@ -171,14 +176,15 @@ export const Select = chakra(
171
176
  >
172
177
  {children}
173
178
  </ChakraSelect>
174
- {footnote && showHelperInvalidText && (
175
- <HelperErrorText
176
- id={`${id}-helperText`}
177
- isInvalid={isInvalid}
178
- text={footnote}
179
- />
180
- )}
181
179
  </Box>
180
+ {footnote && showHelperInvalidText && (
181
+ <HelperErrorText
182
+ id={`${id}-helperText`}
183
+ isInvalid={isInvalid}
184
+ text={footnote}
185
+ ml={{ sm: "auto", md: `${labelWidth}px` }}
186
+ />
187
+ )}
182
188
  </Box>
183
189
  );
184
190
  }
@@ -280,19 +280,19 @@ exports[`Select Renders the UI snapshot correctly 3`] = `
280
280
  </svg>
281
281
  </div>
282
282
  </div>
283
- <div
284
- aria-atomic={true}
285
- aria-live="polite"
286
- className="css-1xdhyk6"
287
- dangerouslySetInnerHTML={
288
- Object {
289
- "__html": "Tom doesn't count as a sibling :(.",
290
- }
291
- }
292
- data-isinvalid={true}
293
- id="select-helperText"
294
- />
295
283
  </div>
284
+ <div
285
+ aria-atomic={true}
286
+ aria-live="polite"
287
+ className="css-1mpebub"
288
+ dangerouslySetInnerHTML={
289
+ Object {
290
+ "__html": "Tom doesn't count as a sibling :(.",
291
+ }
292
+ }
293
+ data-isinvalid={true}
294
+ id="select-helperText"
295
+ />
296
296
  </div>
297
297
  `;
298
298
 
@@ -387,19 +387,19 @@ exports[`Select Renders the UI snapshot correctly 4`] = `
387
387
  </svg>
388
388
  </div>
389
389
  </div>
390
- <div
391
- aria-atomic={true}
392
- aria-live="off"
393
- className="css-1xdhyk6"
394
- dangerouslySetInnerHTML={
395
- Object {
396
- "__html": "Remember, Logan will judge you no matter who you pick.",
397
- }
398
- }
399
- data-isinvalid={false}
400
- id="select-helperText"
401
- />
402
390
  </div>
391
+ <div
392
+ aria-atomic={true}
393
+ aria-live="off"
394
+ className="css-1mpebub"
395
+ dangerouslySetInnerHTML={
396
+ Object {
397
+ "__html": "Remember, Logan will judge you no matter who you pick.",
398
+ }
399
+ }
400
+ data-isinvalid={false}
401
+ id="select-helperText"
402
+ />
403
403
  </div>
404
404
  `;
405
405
 
package/src/index.ts CHANGED
@@ -69,7 +69,7 @@ export { default as List } from "./components/List/List";
69
69
  export { ListTypes } from "./components/List/ListTypes";
70
70
  export { default as Logo } from "./components/Logo/Logo";
71
71
  export { LogoColors, LogoNames, LogoSizes } from "./components/Logo/LogoTypes";
72
- export { default as Modal } from "./components/Modal/Modal";
72
+ export { ModalTrigger, useModal } from "./components/Modal/Modal";
73
73
  export { default as Notification } from "./components/Notification/Notification";
74
74
  export { NotificationTypes } from "./components/Notification/NotificationTypes";
75
75
  export { default as Pagination } from "./components/Pagination/Pagination";
package/src/styles.scss CHANGED
@@ -21,4 +21,3 @@
21
21
  // Components.
22
22
  @import "./components/Autosuggest/_Autosuggest.scss";
23
23
  @import "./components/DatePicker/_DatePicker.scss";
24
- @import "./components/Modal/_Modal.scss";
@@ -29,7 +29,7 @@ const select = {
29
29
 
30
30
  const Select = {
31
31
  parts: ["helperText", "inline", "select"],
32
- baseStyle: ({ labelPosition, labelWidth }) => {
32
+ baseStyle: ({ labelPosition }) => {
33
33
  return {
34
34
  marginBottom: "xs",
35
35
  // The backgroundColor set to "ui.white" hides the arrow SVG icon when
@@ -38,10 +38,6 @@ const Select = {
38
38
  ".chakra-select__icon-wrapper": {
39
39
  zIndex: "9999",
40
40
  },
41
- helperText: {
42
- marginLeft:
43
- labelPosition === "inline" ? { md: `${labelWidth}px` } : null,
44
- },
45
41
  inline: {
46
42
  display: { md: "flex" },
47
43
  gap: { md: "var(--nypl-space-xs)" },
@@ -1,18 +0,0 @@
1
- .no-scroll {
2
- overflow: hidden;
3
- position: fixed;
4
- top: -100vh;
5
- }
6
-
7
- .modal {
8
- padding: var(--nypl-space-s);
9
-
10
- background-color: var(--nypl-colors-ui-gray-x-light-warm);
11
- height: 100vh;
12
- left: 0;
13
- overflow-y: auto;
14
- position: fixed;
15
- top: 0;
16
- width: 100vw;
17
- z-index: 999;
18
- }