-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathModal.component.tsx
More file actions
387 lines (340 loc) · 10.4 KB
/
Copy pathModal.component.tsx
File metadata and controls
387 lines (340 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
/*
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
useState,
useEffect,
useRef,
useId,
ReactNode,
MouseEvent,
HTMLProps,
ReactElement,
MouseEventHandler,
} from "react"
import { createPortal } from "react-dom"
import { FocusTrap } from "focus-trap-react"
import { ModalFooter } from "../ModalFooter/index"
import { Icon, KnownIcons } from "../Icon/Icon.component"
import { ButtonVariant } from "../Button/index"
import { usePortalRef } from "../PortalProvider/PortalProvider.component"
/*
* handle height/scrolling TODO -> allow optional constrainHeight=false prop?
* Unfocus element on ESC, close modal on ESC when no element is focussed
* Spare "variant" prop for semantic variants later.
* a11y (voicereader, keyboard accessibilty) TODO
*/
const modalcontainerstyles = `
jn:fixed
jn:inset-0
jn:flex
jn:items-center
jn:bg-theme-modal-backdrop
jn:backdrop-blur-[2px]
jn:z-9990
`
const modalstyles = `
jn:bg-theme-background-lvl-0
jn:relative
jn:rounded
jn:m-auto
jn:overflow-y-auto
jn:max-h-[90%]
`
const headerstyles = `
jn:flex
jn:items-start
jn:py-2
jn:px-8
jn:border-b
jn:border-theme-background-lvl-4
jn:min-h-[2.8125rem]
`
const contentstyles = `
jn:min-h-[5rem]
`
const contentpaddingstyles = `
jn:py-4
jn:px-8
`
const sizeClass = (size: ModalSize) => {
switch (size) {
case "large":
return `jn:w-[40rem]`
case "xl":
return `jn:w-[76.75rem]`
case "2xl":
return `jn:w-[80%] jn:min-w-[85rem] jn:max-w-[112.5rem]`
default:
return `jn:w-[33.625rem]`
}
}
/**
* The `Modal` component provides a flexible dialog window for user interactions,
* supporting titles, dismissal controls, sizing options, and comprehensive footer configurations.
* @see https://cloudoperators.github.io/juno/?path=/docs/components-modal-modal--docs
* @see {@link ModalProps}
*/
export const Modal = ({
title = "",
heading = "",
ariaLabel,
initialFocus,
open = false,
closeable = true,
closeOnEsc = true,
closeOnBackdropClick = false,
disableCloseButton,
size = "small",
unpad = false,
className = "",
children,
modalFooter,
confirmButtonLabel = "",
cancelButtonLabel = "",
confirmButtonIcon,
confirmButtonVariant,
cancelButtonIcon,
disableConfirmButton = false,
disableCancelButton = false,
onConfirm,
onCancel,
...props
}: ModalProps): ReactNode => {
const id = "juno-modal-" + useId()
const [isOpen, setIsOpen] = useState(open)
const [isCloseable, setIsCloseable] = useState(closeable)
const [isCloseabelOnBackdropClick, setIsCloseableOnBackdropClick] = useState(closeOnBackdropClick)
const [isCloseableOnEsc, setisCloseableOnEsc] = useState(closeOnEsc)
useEffect(() => {
setIsOpen(open)
}, [open])
useEffect(() => {
setIsCloseable(closeable)
}, [closeable])
useEffect(() => {
setIsCloseableOnBackdropClick(closeOnBackdropClick)
}, [closeOnBackdropClick])
useEffect(() => {
setisCloseableOnEsc(closeOnEsc)
}, [closeOnEsc])
const handleConfirmClick = (event: MouseEvent<HTMLElement>) => {
onConfirm && onConfirm(event)
}
const handleCancelClick = (event: MouseEvent<HTMLElement>) => {
setIsOpen(false)
onCancel && onCancel(event)
}
const handleEsc = (event: KeyboardEvent) => {
if (isCloseable && isCloseableOnEsc) {
setIsOpen(false)
onCancel && onCancel(event)
}
}
const handleBackdropClick = (event: MouseEvent<HTMLElement>) => {
if (isCloseabelOnBackdropClick) {
setIsOpen(false)
onCancel && onCancel(event)
} else {
event.stopPropagation()
}
}
const portalContainer = usePortalRef()
const modalRef = useRef<HTMLDivElement | null>(null)
const modalTitle = title || heading
const hasTitle = Boolean(modalTitle)
const modalTitleId = hasTitle ? id : undefined
const renderModalTitle = () => {
if (modalTitle === null || modalTitle === undefined || modalTitle === false || modalTitle === "") {
return null
}
if (typeof modalTitle === "string") {
return (
<h4 className="juno-modal-title" id={modalTitleId}>
{modalTitle}
</h4>
)
}
return (
<div className="juno-modal-title juno-h4" role="heading" aria-level={4} id={modalTitleId}>
{modalTitle}
</div>
)
}
return (
<>
{isOpen &&
createPortal(
<div className={`juno-modal-container ${modalcontainerstyles}`} onClick={handleBackdropClick}>
<FocusTrap
focusTrapOptions={{
initialFocus: initialFocus,
clickOutsideDeactivates: isCloseabelOnBackdropClick,
fallbackFocus: () => modalRef.current!,
allowOutsideClick: true,
escapeDeactivates: (e: KeyboardEvent) => {
handleEsc(e)
return false
},
}}
>
<div
className={`juno-modal ${sizeClass(size)} ${modalstyles} ${className}`}
role="dialog"
ref={modalRef}
{...props}
aria-labelledby={modalTitleId}
aria-label={ariaLabel}
>
<div
className={`juno-modal-header ${headerstyles} ${modalTitle ? `jn:justify-between` : `jn:justify-end`}`}
>
{renderModalTitle()}
{isCloseable ? (
<Icon
icon="close"
className="jn:self-stretch jn:flex jn:items-start jn:pt-1"
onClick={handleCancelClick}
disabled={disableCancelButton || disableCloseButton}
/>
) : (
""
)}
</div>
<div className={`juno-modal-content ${contentstyles} ${unpad ? "" : contentpaddingstyles}`}>
{children}
</div>
{isCloseable ? (
modalFooter ? (
modalFooter
) : (
<ModalFooter
confirmButtonLabel={confirmButtonLabel}
cancelButtonLabel={cancelButtonLabel}
confirmButtonIcon={confirmButtonIcon}
confirmButtonVariant={confirmButtonVariant}
cancelButtonIcon={cancelButtonIcon}
disableConfirmButton={disableConfirmButton}
disableCancelButton={disableCancelButton}
onConfirm={onConfirm ? handleConfirmClick : undefined}
onCancel={handleCancelClick}
/>
)
) : null}
</div>
</FocusTrap>
</div>,
portalContainer ? portalContainer : document.body
)}
</>
)
}
type ModalSize = "small" | "large" | "xl" | "2xl"
export interface ModalProps extends Omit<HTMLProps<HTMLDivElement>, "size" | "title"> {
/**
* The title of the modal. This will be rendering as the heading of the modal, and the modal's `aria-labelledby` attribute will reference the title/heading element. If the modal does not have `title` or `heading`, use `ariaLabel` to provide an accessible name for the modal.
*/
title?: ReactNode
/**
* Also the title of the modal, just for API flexibility. If both `title` and `heading` are passed, `title` will take precedence.
*/
heading?: ReactNode
/**
* The aria-label of the modal. Use only if the modal does NOT have a `title` or `heading`.
*/
ariaLabel?: string
/**
* By default, the first element in the tab order of the Modal content will be focussed. To specify an element to be focussed when the modal opens, pass an element, DOM node, or selector string.
*/
initialFocus?: HTMLElement | SVGElement | string
/**
* Whether the modal will be open.
* @default false
*/
open?: boolean
/**
* Whether the modal can be closed using an "X"-Button at the top right.
* @default true
*/
closeable?: boolean
/**
* Whether the modal should be closed when the backdrop is clicked. Essentially 'un-modals' the modal.
* @default false
*/
closeOnBackdropClick?: boolean
/**
* Determines whether the close button should be disabled.
* @default false
*/
disableCloseButton?: boolean
/**
* Whether the modal can be closed by hitting the ESC key.
* @default true
*/
closeOnEsc?: boolean
/**
* The Modal size, determines the aesthetics of the modal.
* @default small
*/
size?: ModalSize
/**
* Pass to remove default padding from the content area of the modal.
* @default false
*/
unpad?: boolean
/**
* Custom className to add to the modal for additional styling.
* @default ""
*/
className?: string
/**
* The children of the modal. These will be rendered as the modal content. To render custom buttons at the bottom, see `modalFooter` below.
*/
children?: ReactNode
/**
* Optional. Pass a `<ModalFooter />` component with custom content as required. Will default to using the `<ModalFooter/>` component internally.
*/
modalFooter?: ReactElement
/**
* Pass a label to render a confirm button and a Cancel button.
* @default ""
*/
confirmButtonLabel?: string
/**
* Pass a label for the cancel button. Defaults to "Cancel".
* @default "Cancel"
*/
cancelButtonLabel?: string
/**
* Pass an Icon name to show on the confirming action button.
*/
confirmButtonIcon?: KnownIcons
/**
* The variant of the confirm button.
* @default "primary"
*/
confirmButtonVariant?: ButtonVariant
/**
* Pass an icon name to show on the cancelling button.
*/
cancelButtonIcon?: KnownIcons
/**
* Determines whether the confirm action button should be disabled.
* @default false
*/
disableConfirmButton?: boolean
/**
* Determines whether the cancel action button should be disabled.
* @default false
*/
disableCancelButton?: boolean
/**
* A handler to execute once the modal is confirmed by clicking the confirm button if exists. Note that we do not close the modal automatically.
*/
onConfirm?: MouseEventHandler<HTMLElement>
/**
* A handler to execute once the modal is cancelled or dismissed using the x-Close button, Cancel-button or pressing ESC.
*/
onCancel?: (_event: MouseEvent<HTMLElement> | KeyboardEvent) => void
}