Passed
Push — task/update-profile-experience ( e35027...3aacad )
by Tristan
05:18
created

resources/assets/js/components/Modal.tsx   A

Complexity

Total Complexity 12
Complexity/F 6

Size

Lines of Code 216
Function Count 2

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 12
eloc 164
mnd 10
bc 10
fnc 2
dl 0
loc 216
rs 10
bpm 5
cpm 6
noi 0
c 0
b 0
f 0
1
import React, { createContext, useContext, useEffect, useRef } from "react";
2
import { createPortal } from "react-dom";
3
4
interface ModalProps {
5
  id: string;
6
  parentElement: Element | null;
7
  visible: boolean;
8
  children: React.ReactNode;
9
  className?: string;
10
  onModalConfirm: (e: React.MouseEvent<HTMLButtonElement>) => void;
11
  onModalMiddle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
12
  onModalCancel: (
13
    e: React.MouseEvent<HTMLButtonElement> | KeyboardEvent,
14
  ) => void;
15
}
16
17
// Partial helper allows empty defaults in the createContext call:
18
// https://fettblog.eu/typescript-react/context/#context-without-default-values
19
const modalContext = createContext<Partial<ModalProps>>({});
20
21
export default function Modal({
22
  id,
23
  parentElement,
24
  visible,
25
  children,
26
  className,
27
  onModalConfirm,
28
  onModalMiddle,
29
  onModalCancel,
30
}: ModalProps): React.ReactPortal | null {
31
  // Set up div ref to measure modal height
32
  const modalRef = useRef<HTMLDivElement>(null);
33
34
  const handleTabKey = (e: KeyboardEvent): void => {
35
    if (modalRef && modalRef.current) {
36
      const focusableModalElements = modalRef.current.querySelectorAll(
37
        'a[href], button, textarea, input[type="text"], input[type="email"], input[type="radio"], select',
38
      );
39
      const firstElement = focusableModalElements[0] as HTMLElement;
40
      const lastElement = focusableModalElements[
41
        focusableModalElements.length - 1
42
      ] as HTMLElement;
43
44
      const focusableModalElementsArray = Array.from(focusableModalElements);
45
46
      if (
47
        document.activeElement &&
48
        !focusableModalElementsArray.includes(document.activeElement)
49
      ) {
50
        firstElement.focus();
51
        e.preventDefault();
52
      }
53
54
      if (!e.shiftKey && document.activeElement === lastElement) {
55
        firstElement.focus();
56
        e.preventDefault();
57
      }
58
59
      if (e.shiftKey && document.activeElement === firstElement) {
60
        lastElement.focus();
61
        e.preventDefault();
62
      }
63
    }
64
  };
65
66
  // Collection of key codes and event listeners
67
  const keyListenersMap = new Map([
68
    [27, onModalCancel],
69
    [9, handleTabKey],
70
  ]);
71
72
  // Runs every time visible changes to set the overflow on the modal and update the body overflow
73
  useEffect((): (() => void) => {
74
    function setBodyStyle(): void {
75
      document.body.style.overflow = visible ? "hidden" : "visible";
76
    }
77
    setBodyStyle();
78
    // Runs on component unmount
79
    return (): void => {
80
      setBodyStyle();
81
    };
82
  }, [visible]);
83
84
  // Adds various key commands to the modal
85
  useEffect((): (() => void) => {
86
    let keyListener;
87
    if (visible) {
88
      keyListener = (e: KeyboardEvent): void => {
89
        const listener = keyListenersMap.get(e.keyCode);
90
        return listener && listener(e);
91
      };
92
      document.addEventListener("keydown", keyListener);
93
    }
94
95
    return (): void => {
96
      if (keyListener !== undefined) {
97
        document.removeEventListener("keydown", keyListener);
98
      }
99
    };
100
  }, [keyListenersMap, visible]);
101
102
  if (parentElement !== null) {
103
    return createPortal(
104
      <div
105
        aria-describedby={`${id}-description`}
106
        aria-hidden={!visible}
107
        aria-labelledby={`${id}-title`}
108
        data-c-dialog={visible ? "active--overflowing" : ""}
109
        data-c-padding="top(double) bottom(double)"
110
        role="dialog"
111
        ref={modalRef}
112
        className={className}
113
      >
114
        <div data-c-background="white(100)" data-c-radius="rounded">
115
          <modalContext.Provider
116
            value={{
117
              id,
118
              parentElement,
119
              visible,
120
              onModalConfirm,
121
              onModalMiddle,
122
              onModalCancel,
123
            }}
124
          >
125
            {children}
126
          </modalContext.Provider>
127
        </div>
128
      </div>,
129
      parentElement,
130
    );
131
  }
132
133
  return null;
134
}
135
136
Modal.Header = function ModalHeader({ children }): React.ReactElement {
137
  return <div className="dialog-header">{children}</div>;
138
};
139
140
Modal.Body = function ModalBody(props): React.ReactElement {
141
  const { children } = props;
142
  return <div data-c-border="bottom(thin, solid, black)">{children}</div>;
143
};
144
145
Modal.Footer = function ModalFooter(props): React.ReactElement {
146
  const { children } = props;
147
  return (
148
    <div data-c-padding="normal">
149
      <div data-c-grid="gutter middle">
150
        {Array.isArray(children) && children.length > 0
151
          ? children.map(
152
              (btn, index): React.ReactElement => (
153
                <div
154
                  // eslint-disable-next-line react/no-array-index-key
155
                  key={index}
156
                  data-c-grid-item={`base(1of${children.length})`}
157
                >
158
                  {btn}
159
                </div>
160
              ),
161
            )
162
          : children}
163
      </div>
164
    </div>
165
  );
166
};
167
168
Modal.FooterConfirmBtn = function ConfirmBtn(props): React.ReactElement {
169
  const { onModalConfirm } = useContext(modalContext);
170
  return (
171
    <div data-c-alignment="base(right)">
172
      <button
173
        {...props}
174
        data-c-button="solid(c1)"
175
        data-c-dialog-action="close"
176
        data-c-radius="rounded"
177
        type="button"
178
        onClick={onModalConfirm}
179
      />
180
    </div>
181
  );
182
};
183
184
Modal.FooterCancelBtn = function CancelBtn(props): React.ReactElement {
185
  const { onModalCancel } = useContext(modalContext);
186
  return (
187
    <div>
188
      <button
189
        {...props}
190
        data-c-button="outline(c1)"
191
        data-c-dialog-action="close"
192
        data-c-radius="rounded"
193
        type="button"
194
        onClick={onModalCancel}
195
      />
196
    </div>
197
  );
198
};
199
200
Modal.FooterMiddleBtn = function MiddleBtn(props): React.ReactElement {
201
  const { onModalMiddle } = useContext(modalContext);
202
  return (
203
    <div data-c-alignment="base(center)">
204
      <button
205
        {...props}
206
        data-c-button="solid(c1)"
207
        data-c-dialog-action="close"
208
        data-c-radius="rounded"
209
        type="button"
210
        disabled={onModalMiddle === undefined}
211
        onClick={onModalMiddle}
212
      />
213
    </div>
214
  );
215
};
216