@navikt/ds-react 4.12.1 → 5.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.
Files changed (79) hide show
  1. package/_docs.json +101 -127
  2. package/cjs/date/datepicker/DatePicker.js +1 -1
  3. package/cjs/date/hooks/useEscape.js +6 -1
  4. package/cjs/modal/Modal.js +85 -46
  5. package/cjs/modal/{ModalContent.js → ModalBody.js} +3 -3
  6. package/cjs/modal/ModalContext.js +8 -0
  7. package/cjs/modal/ModalFooter.js +46 -0
  8. package/cjs/modal/ModalHeader.js +56 -0
  9. package/cjs/modal/ModalUtils.js +40 -0
  10. package/cjs/modal/dialog-polyfill.js +833 -0
  11. package/cjs/popover/Popover.js +5 -2
  12. package/cjs/table/DataCell.js +1 -3
  13. package/cjs/table/ExpandableRow.js +2 -1
  14. package/cjs/table/HeaderCell.js +1 -4
  15. package/cjs/table/Table.js +1 -1
  16. package/esm/date/datepicker/DatePicker.d.ts +2 -2
  17. package/esm/date/datepicker/DatePicker.js +1 -1
  18. package/esm/date/datepicker/DatePicker.js.map +1 -1
  19. package/esm/date/hooks/useEscape.d.ts +2 -1
  20. package/esm/date/hooks/useEscape.js +6 -1
  21. package/esm/date/hooks/useEscape.js.map +1 -1
  22. package/esm/modal/Modal.d.ts +76 -51
  23. package/esm/modal/Modal.js +87 -48
  24. package/esm/modal/Modal.js.map +1 -1
  25. package/esm/modal/ModalBody.d.ts +6 -0
  26. package/esm/modal/{ModalContent.js → ModalBody.js} +4 -4
  27. package/esm/modal/ModalBody.js.map +1 -0
  28. package/esm/modal/ModalContext.d.ts +6 -0
  29. package/esm/modal/ModalContext.js +3 -0
  30. package/esm/modal/ModalContext.js.map +1 -0
  31. package/esm/modal/ModalFooter.d.ts +6 -0
  32. package/esm/modal/ModalFooter.js +19 -0
  33. package/esm/modal/ModalFooter.js.map +1 -0
  34. package/esm/modal/ModalHeader.d.ts +11 -0
  35. package/esm/modal/ModalHeader.js +29 -0
  36. package/esm/modal/ModalHeader.js.map +1 -0
  37. package/esm/modal/ModalUtils.d.ts +4 -0
  38. package/esm/modal/ModalUtils.js +33 -0
  39. package/esm/modal/ModalUtils.js.map +1 -0
  40. package/esm/modal/dialog-polyfill.d.ts +5 -0
  41. package/esm/modal/dialog-polyfill.js +832 -0
  42. package/esm/modal/dialog-polyfill.js.map +1 -0
  43. package/esm/modal/index.d.ts +3 -1
  44. package/esm/popover/Popover.js +6 -3
  45. package/esm/popover/Popover.js.map +1 -1
  46. package/esm/provider/Provider.d.ts +1 -6
  47. package/esm/provider/Provider.js.map +1 -1
  48. package/esm/table/DataCell.js +2 -4
  49. package/esm/table/DataCell.js.map +1 -1
  50. package/esm/table/ExpandableRow.js +2 -1
  51. package/esm/table/ExpandableRow.js.map +1 -1
  52. package/esm/table/HeaderCell.js +1 -4
  53. package/esm/table/HeaderCell.js.map +1 -1
  54. package/esm/table/Table.d.ts +2 -3
  55. package/esm/table/Table.js +1 -1
  56. package/esm/table/Table.js.map +1 -1
  57. package/package.json +3 -5
  58. package/src/date/datepicker/DatePicker.tsx +3 -3
  59. package/src/date/hooks/useEscape.tsx +8 -3
  60. package/src/modal/Modal.tsx +171 -121
  61. package/src/modal/ModalBody.tsx +14 -0
  62. package/src/modal/ModalContext.ts +6 -0
  63. package/src/modal/ModalFooter.tsx +14 -0
  64. package/src/modal/ModalHeader.tsx +42 -0
  65. package/src/modal/ModalUtils.ts +37 -0
  66. package/src/modal/dialog-polyfill.ts +980 -0
  67. package/src/modal/index.ts +3 -1
  68. package/src/modal/modal.stories.tsx +142 -59
  69. package/src/popover/Popover.tsx +6 -2
  70. package/src/provider/Provider.tsx +1 -6
  71. package/src/table/DataCell.tsx +1 -5
  72. package/src/table/ExpandableRow.tsx +2 -1
  73. package/src/table/HeaderCell.tsx +1 -5
  74. package/src/table/Table.tsx +3 -4
  75. package/src/table/stories/table-expandable.stories.tsx +37 -1
  76. package/src/table/stories/table.stories.tsx +4 -1
  77. package/esm/modal/ModalContent.d.ts +0 -10
  78. package/esm/modal/ModalContent.js.map +0 -1
  79. package/src/modal/ModalContent.tsx +0 -26
@@ -1,76 +1,91 @@
1
- import React, { forwardRef, useEffect, useMemo, useRef } from "react";
1
+ import React, {
2
+ forwardRef,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ } from "react";
2
8
  import cl from "clsx";
3
- import ReactModal from "react-modal";
9
+ import dialogPolyfill from "./dialog-polyfill";
10
+ import { Detail, Heading, mergeRefs, useId } from "..";
11
+ import ModalBody from "./ModalBody";
12
+ import ModalHeader from "./ModalHeader";
13
+ import ModalFooter from "./ModalFooter";
14
+ import { getCloseHandler, useBodyScrollLock } from "./ModalUtils";
15
+ import { ModalContext } from "./ModalContext";
4
16
 
5
- import { Button, mergeRefs, useProvider } from "..";
6
- import ModalContent, { ModalContentType } from "./ModalContent";
7
- import { XMarkIcon } from "@navikt/aksel-icons";
17
+ const needPolyfill =
18
+ typeof window !== "undefined" && window.HTMLDialogElement === undefined;
8
19
 
9
- export interface ModalProps {
20
+ export interface ModalProps
21
+ extends React.DialogHTMLAttributes<HTMLDialogElement> {
10
22
  /**
11
- * Modal content
12
- */
13
- children: React.ReactNode;
14
- /**
15
- * Open state for modal
23
+ * Content for the header. Alteratively you can use <Modal.Header> instead for more control,
24
+ * but then you have to set `aria-label` or `aria-labelledby` on the modal manually.
16
25
  */
17
- open: boolean;
26
+ header?: {
27
+ label?: string;
28
+ icon?: React.ReactNode;
29
+ heading: string;
30
+ /**
31
+ * Heading size
32
+ * @default "medium"
33
+ * */
34
+ size?: "medium" | "small";
35
+ /**
36
+ * Removes close-button (X) when false
37
+ * @default true
38
+ */
39
+ closeButton?: boolean;
40
+ };
18
41
  /**
19
- * Callback for modal wanting to close
42
+ * Modal content
20
43
  */
21
- onClose: () => void;
44
+ children: React.ReactNode;
22
45
  /**
23
- * If modal should close on overlay click (click outside Modal)
24
- * @default true
46
+ * Whether the modal should be visible or not.
47
+ * Remember to use the `onClose` callback to keep your local state in sync.
48
+ * You can also use `ref.current.openModal()` and `ref.current.close()`.
25
49
  */
26
- shouldCloseOnOverlayClick?: boolean;
50
+ open?: boolean;
27
51
  /**
28
- * User defined classname for modal
52
+ * Called when the modal has been closed
29
53
  */
30
- className?: string;
54
+ onClose?: React.ReactEventHandler<HTMLDialogElement>;
31
55
  /**
32
- * User defined classname for modal
56
+ * Called when the user wants to close the modal (clicked the close button or pressed Esc).
57
+ * @returns Whether to close the modal
33
58
  */
34
- overlayClassName?: string;
59
+ onBeforeClose?: () => boolean | void;
35
60
  /**
36
- * Removes close-button(X) when false
37
- * @default true
61
+ * Called when the user presses the Esc key, unless `onBeforeClose()` returns `false`.
38
62
  */
39
- closeButton?: boolean;
63
+ onCancel?: React.ReactEventHandler<HTMLDialogElement>;
40
64
  /**
41
- *
42
- */
43
- shouldCloseOnEsc?: boolean;
65
+ * @default fit-content (up to 700px)
66
+ * */
67
+ width?: "medium" | "small" | number | string;
44
68
  /**
45
- * Allows custom styling of ReactModal, in accordance with their typing
69
+ * User defined classname for modal
46
70
  */
47
- style?: ReactModal.Styles;
71
+ className?: string;
48
72
  /**
49
- * Callback for setting parent element modal will attach to
73
+ * Sets aria-labelledby on modal.
74
+ * No need to set this manually if the `header` prop is used. A reference to `header.heading` will be created automatically.
75
+ * @warning If not using `header`, you should set either `aria-labelledby` or `aria-label`.
50
76
  */
51
- parentSelector?(): HTMLElement;
52
77
  "aria-labelledby"?: string;
53
- "aria-describedby"?: string;
54
- "aria-modal"?: boolean;
55
- /**
56
- * Sets aria-label on modal
57
- * @warning This should be set if not using 'aria-labelledby' or 'aria-describedby'
58
- */
59
- "aria-label"?: string;
60
78
  }
61
79
 
62
80
  interface ModalComponent
63
- extends ModalLifecycle,
64
- React.ForwardRefExoticComponent<
65
- ModalProps & React.RefAttributes<ReactModal>
66
- > {
67
- Content: ModalContentType;
81
+ extends React.ForwardRefExoticComponent<
82
+ ModalProps & React.RefAttributes<HTMLDialogElement>
83
+ > {
84
+ Header: typeof ModalHeader;
85
+ Body: typeof ModalBody;
86
+ Footer: typeof ModalFooter;
68
87
  }
69
88
 
70
- type ModalLifecycle = {
71
- setAppElement: (element: any) => void;
72
- };
73
-
74
89
  /**
75
90
  * A component that displays a modal dialog.
76
91
  *
@@ -78,112 +93,147 @@ type ModalLifecycle = {
78
93
  * @see 🏷️ {@link ModalProps}
79
94
  *
80
95
  * @example
96
+ * State change with `useRef`
97
+ * ```jsx
98
+ * const ref = useRef<HTMLDialogElement>(null);
99
+ * <Button onClick={() => ref.current?.showModal()}>Open modal</Button>
100
+ * <Modal
101
+ * ref={ref}
102
+ * header={{
103
+ * label: "Optional label",
104
+ * icon: <FileIcon aria-hidden />,
105
+ * heading: "My heading",
106
+ * }}
107
+ * >
108
+ * <Modal.Body>
109
+ * <BodyLong>Hello world</BodyLong>
110
+ * </Modal.Body>
111
+ * <Modal.Footer>
112
+ * <Button>Save</Button>
113
+ * <Button type="button" variant="tertiary" onClick={() => ref.current?.close()}>Close</Button>
114
+ * </Modal.Footer>
115
+ * </Modal>
116
+ * ```
117
+ * @example
118
+ * State change with `useState`
81
119
  * ```jsx
82
120
  * const [open, setOpen] = useState(false);
83
- *
84
121
  * <Modal
85
122
  * open={open}
86
- * aria-label="Modal demo"
87
- * onClose={() => setOpen((x) => !x)}
123
+ * onClose={() => setOpen(false)}
88
124
  * aria-labelledby="modal-heading"
89
125
  * >
90
- * <Modal.Content>
91
- * <Heading spacing level="1" size="large" id="modal-heading">
92
- * Viktig info
93
- * </Heading>
94
- * <BodyLong spacing>
95
- * Hallo!
96
- * </BodyLong>
97
- * </Modal.Content>
126
+ * <Modal.Header>
127
+ * <Heading level="1" size="large" id="modal-heading">My heading</Heading>
128
+ * </Modal.Header>
129
+ * <Modal.Body>
130
+ * <BodyLong>Hello world</BodyLong>
131
+ * </Modal.Body>
98
132
  * </Modal>
99
133
  * ```
100
134
  */
101
- export const Modal = forwardRef<ReactModal, ModalProps>(
135
+ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
102
136
  (
103
137
  {
138
+ header,
104
139
  children,
105
140
  open,
106
- onClose,
141
+ onBeforeClose,
142
+ onCancel,
143
+ width,
107
144
  className,
108
- overlayClassName,
109
- shouldCloseOnOverlayClick = true,
110
- shouldCloseOnEsc = true,
111
- closeButton = true,
112
- "aria-describedby": ariaDescribedBy,
113
- "aria-labelledby": ariaLabelledBy,
114
- "aria-modal": ariaModal,
115
- "aria-label": contentLabel,
145
+ "aria-labelledby": ariaLabelledby,
116
146
  style,
117
- parentSelector,
118
147
  ...rest
119
- },
148
+ }: ModalProps,
120
149
  ref
121
150
  ) => {
122
- const modalRef = useRef<ReactModal | null>(null);
151
+ const modalRef = useRef<HTMLDialogElement>(null);
123
152
  const mergedRef = useMemo(() => mergeRefs([modalRef, ref]), [ref]);
124
- const buttonRef = useRef<HTMLButtonElement>(null);
125
- const rootElement = useProvider()?.rootElement;
126
- const appElement = useProvider()?.appElement;
153
+ const ariaLabelId = useId();
127
154
 
128
- useEffect(() => {
129
- appElement && Modal.setAppElement(appElement);
130
- }, [appElement]);
155
+ if (useContext(ModalContext)) {
156
+ console.error("Modals should not be nested");
157
+ }
131
158
 
132
- const onModalCloseRequest = (e) => {
133
- if (shouldCloseOnOverlayClick || e.type === "keydown") {
134
- onClose();
135
- } else if (buttonRef.current) {
136
- buttonRef.current.focus();
159
+ useEffect(() => {
160
+ if (needPolyfill && modalRef.current) {
161
+ dialogPolyfill.registerDialog(modalRef.current);
137
162
  }
138
- };
163
+ }, [modalRef]);
139
164
 
140
- const getParentSelector = () => {
141
- if (parentSelector) {
142
- return parentSelector;
165
+ useEffect(() => {
166
+ // We need to have this in a useEffect so that the content renders before the modal is displayed,
167
+ // and in case `open` is true initially.
168
+ if (modalRef.current && open !== undefined) {
169
+ if (open && !modalRef.current.open) {
170
+ modalRef.current.showModal();
171
+ } else if (!open && modalRef.current.open) {
172
+ modalRef.current.close();
173
+ }
143
174
  }
144
- return rootElement !== undefined
145
- ? () => rootElement as HTMLElement
146
- : undefined;
147
- };
175
+ }, [modalRef, open]);
176
+
177
+ useBodyScrollLock(modalRef, "navds-modal__document-body");
178
+
179
+ const isWidthPreset =
180
+ typeof width === "string" && ["small", "medium"].includes(width);
148
181
 
149
182
  return (
150
- <ReactModal
151
- {...rest}
152
- parentSelector={getParentSelector()}
153
- style={style}
154
- isOpen={open}
183
+ <dialog
155
184
  ref={mergedRef}
156
- className={cl("navds-modal", className)}
157
- overlayClassName={cl("navds-modal__overlay", overlayClassName)}
158
- shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
159
- shouldCloseOnEsc={shouldCloseOnEsc}
160
- onRequestClose={(e) => onModalCloseRequest(e)}
161
- aria={{
162
- describedby: ariaDescribedBy,
163
- labelledby: ariaLabelledBy,
164
- modal: ariaModal,
185
+ className={cl("navds-modal", className, {
186
+ "navds-modal--polyfilled": needPolyfill,
187
+ "navds-modal--autowidth": !width,
188
+ [`navds-modal--${width}`]: isWidthPreset,
189
+ })}
190
+ style={{
191
+ ...style,
192
+ ...(!isWidthPreset ? { width } : {}),
165
193
  }}
166
- contentLabel={contentLabel}
194
+ onCancel={(event) => {
195
+ // FYI: onCancel fires when you press Esc
196
+ if (onBeforeClose && onBeforeClose() === false) {
197
+ event.preventDefault();
198
+ } else if (onCancel) onCancel(event);
199
+ }}
200
+ aria-labelledby={
201
+ !ariaLabelledby && !rest["aria-label"] && header
202
+ ? ariaLabelId
203
+ : ariaLabelledby
204
+ }
205
+ {...rest}
167
206
  >
168
- {children}
169
- {closeButton && (
170
- <Button
171
- className={cl("navds-modal__button", {
172
- "navds-modal__button--shake": shouldCloseOnOverlayClick,
173
- })}
174
- size="small"
175
- variant="tertiary-neutral"
176
- ref={buttonRef}
177
- onClick={onClose}
178
- icon={<XMarkIcon title="Lukk modalvindu" />}
179
- />
180
- )}
181
- </ReactModal>
207
+ <ModalContext.Provider
208
+ value={{
209
+ closeHandler: getCloseHandler(modalRef, header, onBeforeClose),
210
+ }}
211
+ >
212
+ {header && (
213
+ <ModalHeader>
214
+ {header.label && (
215
+ <Detail className="navds-modal__label">{header.label}</Detail>
216
+ )}
217
+ <Heading
218
+ size={header.size ?? "medium"}
219
+ level="1"
220
+ id={ariaLabelId}
221
+ >
222
+ <span className="navds-modal__header-icon">{header.icon}</span>
223
+ {header.heading}
224
+ </Heading>
225
+ </ModalHeader>
226
+ )}
227
+
228
+ {children}
229
+ </ModalContext.Provider>
230
+ </dialog>
182
231
  );
183
232
  }
184
233
  ) as ModalComponent;
185
234
 
186
- Modal.setAppElement = (element) => ReactModal.setAppElement(element);
187
- Modal.Content = ModalContent;
235
+ Modal.Header = ModalHeader;
236
+ Modal.Body = ModalBody;
237
+ Modal.Footer = ModalFooter;
188
238
 
189
239
  export default Modal;
@@ -0,0 +1,14 @@
1
+ import React, { forwardRef } from "react";
2
+ import cl from "clsx";
3
+
4
+ export interface ModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(
9
+ ({ className, ...rest }, ref) => (
10
+ <div {...rest} ref={ref} className={cl("navds-modal__body", className)} />
11
+ )
12
+ );
13
+
14
+ export default ModalBody;
@@ -0,0 +1,6 @@
1
+ import React from "react";
2
+
3
+ interface ModalContextProps {
4
+ closeHandler?: React.MouseEventHandler<HTMLButtonElement>;
5
+ }
6
+ export const ModalContext = React.createContext<ModalContextProps | null>(null);
@@ -0,0 +1,14 @@
1
+ import React, { forwardRef } from "react";
2
+ import cl from "clsx";
3
+
4
+ export interface ModalFooterProps extends React.HTMLAttributes<HTMLDivElement> {
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(
9
+ ({ className, ...rest }, ref) => (
10
+ <div {...rest} ref={ref} className={cl("navds-modal__footer", className)} />
11
+ )
12
+ );
13
+
14
+ export default ModalFooter;
@@ -0,0 +1,42 @@
1
+ import React, { forwardRef, useContext } from "react";
2
+ import cl from "clsx";
3
+ import { XMarkIcon } from "@navikt/aksel-icons";
4
+ import { Button } from "../button";
5
+ import { ModalContext } from "./ModalContext";
6
+
7
+ export interface ModalHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
8
+ children?: React.ReactNode;
9
+ /**
10
+ * Removes close-button (X) when false
11
+ * @default true
12
+ */
13
+ closeButton?: boolean;
14
+ }
15
+
16
+ const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(
17
+ ({ children, className, closeButton = true, ...rest }, ref) => {
18
+ const context = useContext(ModalContext);
19
+ if (context === null) {
20
+ console.error("<Modal.Header> has to be used within a <Modal>");
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <div {...rest} ref={ref} className={cl("navds-modal__header", className)}>
26
+ {context.closeHandler && closeButton && (
27
+ <Button
28
+ type="button"
29
+ className="navds-modal__button"
30
+ size="small"
31
+ variant="tertiary-neutral"
32
+ onClick={context.closeHandler}
33
+ icon={<XMarkIcon title="Lukk modalvindu" />}
34
+ />
35
+ )}
36
+ {children}
37
+ </div>
38
+ );
39
+ }
40
+ );
41
+
42
+ export default ModalHeader;
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import type { ModalProps } from "./Modal";
3
+
4
+ export function getCloseHandler(
5
+ modalRef: React.RefObject<HTMLDialogElement>,
6
+ header: ModalProps["header"],
7
+ onBeforeClose: ModalProps["onBeforeClose"]
8
+ ) {
9
+ if (header && header.closeButton === false) return undefined;
10
+ if (onBeforeClose) {
11
+ return () => onBeforeClose() !== false && modalRef.current?.close();
12
+ }
13
+ return () => modalRef.current?.close();
14
+ }
15
+
16
+ export function useBodyScrollLock(
17
+ modalRef: React.RefObject<HTMLDialogElement>,
18
+ bodyClass: string
19
+ ) {
20
+ React.useEffect(() => {
21
+ if (!modalRef.current) return;
22
+ if (modalRef.current.open) document.body.classList.add(bodyClass); // In case `open` is true initially
23
+
24
+ const observer = new MutationObserver(() => {
25
+ if (modalRef.current?.open) document.body.classList.add(bodyClass);
26
+ else document.body.classList.remove(bodyClass);
27
+ });
28
+ observer.observe(modalRef.current, {
29
+ attributes: true,
30
+ attributeFilter: ["open"],
31
+ });
32
+ return () => {
33
+ observer.disconnect();
34
+ document.body.classList.remove(bodyClass); // In case modal is unmounted before it's closed
35
+ };
36
+ }, [modalRef, bodyClass]);
37
+ }