@stack-spot/portal-layout 1.0.2 → 1.1.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.
- package/CHANGELOG.md +12 -0
- package/dist/Layout.d.ts +2 -2
- package/dist/Layout.js +1 -1
- package/dist/LayoutOverlayManager.js +6 -6
- package/dist/LayoutOverlayManager.js.map +1 -1
- package/dist/components/Dialog.d.ts +1 -1
- package/dist/components/Dialog.js +1 -1
- package/dist/components/Header.d.ts +1 -1
- package/dist/components/Header.js +1 -1
- package/dist/components/OverlayContent.d.ts +1 -1
- package/dist/components/OverlayContent.js +20 -20
- package/dist/components/PortalSwitcher.d.ts +1 -1
- package/dist/components/PortalSwitcher.js +54 -54
- package/dist/components/Toaster.d.ts +2 -2
- package/dist/components/Toaster.js +1 -1
- package/dist/components/UserMenu.d.ts +1 -1
- package/dist/components/UserMenu.d.ts.map +1 -1
- package/dist/components/UserMenu.js +44 -42
- package/dist/components/UserMenu.js.map +1 -1
- package/dist/components/error/ErrorBoundary.d.ts +1 -1
- package/dist/components/error/ErrorBoundary.js +1 -1
- package/dist/components/error/SilentErrorBoundary.d.ts +1 -1
- package/dist/components/error/SilentErrorBoundary.js +1 -1
- package/dist/components/menu/MenuContent.d.ts +2 -2
- package/dist/components/menu/MenuContent.js +123 -123
- package/dist/components/menu/MenuContent.js.map +1 -1
- package/dist/components/menu/MenuSections.d.ts +1 -1
- package/dist/components/menu/MenuSections.js +1 -1
- package/dist/components/menu/MenuSections.js.map +1 -1
- package/dist/components/menu/PageSelector.d.ts +1 -1
- package/dist/components/menu/PageSelector.js +69 -69
- package/dist/components/menu/PageSelector.js.map +1 -1
- package/dist/components/tour/PortalSwitcherStep.js +1 -1
- package/dist/components/user-menu-manager.d.ts +13 -0
- package/dist/components/user-menu-manager.d.ts.map +1 -0
- package/dist/components/user-menu-manager.js +36 -0
- package/dist/components/user-menu-manager.js.map +1 -0
- package/dist/layout.css +477 -477
- package/dist/toaster.js +1 -1
- package/package.json +9 -6
- package/readme.md +146 -146
- package/src/Layout.tsx +171 -171
- package/src/LayoutOverlayManager.tsx +464 -464
- package/src/components/Dialog.tsx +140 -140
- package/src/components/Header.tsx +62 -62
- package/src/components/OverlayContent.tsx +80 -80
- package/src/components/PortalSwitcher.tsx +161 -161
- package/src/components/Toaster.tsx +95 -95
- package/src/components/UserMenu.tsx +127 -124
- package/src/components/error/ErrorBoundary.tsx +47 -47
- package/src/components/error/ErrorManager.ts +47 -47
- package/src/components/error/SilentErrorBoundary.tsx +64 -64
- package/src/components/menu/MenuContent.tsx +270 -270
- package/src/components/menu/MenuSections.tsx +320 -320
- package/src/components/menu/PageSelector.tsx +164 -164
- package/src/components/menu/constants.ts +2 -2
- package/src/components/menu/types.ts +205 -205
- package/src/components/tour/PortalSwitcherStep.tsx +39 -39
- package/src/components/types.ts +1 -1
- package/src/components/user-menu-manager.ts +31 -0
- package/src/dictionary.ts +28 -28
- package/src/elements.ts +30 -30
- package/src/errors.ts +11 -11
- package/src/index.ts +14 -14
- package/src/layout.css +477 -477
- package/src/toaster.tsx +153 -153
- package/src/utils.ts +29 -29
- package/tsconfig.json +8 -8
|
@@ -1,464 +1,464 @@
|
|
|
1
|
-
/* eslint-disable react-hooks/rules-of-hooks */
|
|
2
|
-
|
|
3
|
-
import { Button } from '@citric/core'
|
|
4
|
-
import { focusAccessibleElement, focusFirstChild } from '@stack-spot/portal-components'
|
|
5
|
-
import { ReactElement, useLayoutEffect, useState } from 'react'
|
|
6
|
-
import { Dialog, DialogOptions } from './components/Dialog'
|
|
7
|
-
import { CLOSE_OVERLAY_ID, OverlayContent, OverlayContentProps } from './components/OverlayContent'
|
|
8
|
-
import { getDictionary } from './dictionary'
|
|
9
|
-
import { LayoutElements, elementIds, getLayoutElements } from './elements'
|
|
10
|
-
import { ElementNotFound, LayoutError } from './errors'
|
|
11
|
-
import { CustomToasterOptions, DefaultToasterOptions, closeReactToaster, showToaster as showReactToaster } from './toaster'
|
|
12
|
-
import { valueOfLayoutVar } from './utils'
|
|
13
|
-
|
|
14
|
-
interface AlertOptions extends Omit<DialogOptions, 'cancel'> {
|
|
15
|
-
/**
|
|
16
|
-
* Whether or not to show an "ok" button. If false, the dialog can still be closed through the close button, by clicking outside it or by
|
|
17
|
-
* pressing ESC.
|
|
18
|
-
*/
|
|
19
|
-
showButton?: boolean,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
type BottomDialogOptions = Omit<DialogOptions, 'title'>
|
|
23
|
-
type OverlaySize = 'small' | 'medium' | 'large'
|
|
24
|
-
type ModalSize = 'fit-content' | OverlaySize
|
|
25
|
-
type SetContentFn = ((content: ReactElement | undefined) => void) | undefined
|
|
26
|
-
|
|
27
|
-
interface OverlayContentSetter {
|
|
28
|
-
modal?: SetContentFn,
|
|
29
|
-
rightPanel?: SetContentFn,
|
|
30
|
-
bottomDialog?: SetContentFn,
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface CustomModalOptions {
|
|
34
|
-
/**
|
|
35
|
-
* The size of the modal.
|
|
36
|
-
*/
|
|
37
|
-
size?: ModalSize,
|
|
38
|
-
/**
|
|
39
|
-
* A function to call when the modal closes.
|
|
40
|
-
*/
|
|
41
|
-
onClose?: () => void,
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface CustomRightPanelOptions {
|
|
45
|
-
/**
|
|
46
|
-
* The size of the right panel.
|
|
47
|
-
*/
|
|
48
|
-
size?: OverlaySize,
|
|
49
|
-
/**
|
|
50
|
-
* A function to call when the right panel closes.
|
|
51
|
-
*/
|
|
52
|
-
onClose?: () => void,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function multipleCallsWarning(type: 'modal' | 'rightPanel', timeMS: number) {
|
|
56
|
-
return `
|
|
57
|
-
Attempted to show a modal or rightPanel while a ${type} was still being closed. Closing a ${type} takes only ${timeMS}ms, so this action
|
|
58
|
-
is unlikely to have been triggered by the user and may point to errors in your code. Please check.\nTip: showModal and showRightPanel
|
|
59
|
-
are sideEffects and should never be called during the render of a component. Try to use "useEffect" or link it to an event, like
|
|
60
|
-
"onClick".
|
|
61
|
-
`.replace(/\s*\n\s+/g, ' ')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
class LayoutOverlayManager {
|
|
65
|
-
static readonly instance?: LayoutOverlayManager
|
|
66
|
-
private setContent: OverlayContentSetter = {}
|
|
67
|
-
private elements?: LayoutElements
|
|
68
|
-
private onModalClose?: () => void
|
|
69
|
-
/**
|
|
70
|
-
* Last element with focus before an overlay is shown.
|
|
71
|
-
*/
|
|
72
|
-
private lastActiveElement: Element | null = null
|
|
73
|
-
|
|
74
|
-
private setupElements() {
|
|
75
|
-
this.elements = getLayoutElements()
|
|
76
|
-
this.elements.backdrop?.addEventListener('mousedown', (event) => {
|
|
77
|
-
if (this.isModalOpen()) !this.elements?.modal?.contains?.(event.target as Node) && this.closeModal()
|
|
78
|
-
else if (this.isRightPanelOpen()) !this.elements?.rightPanel?.contains?.(event.target as Node) && this.closeRightPanel()
|
|
79
|
-
else this.setMainContentInteractivity(true)
|
|
80
|
-
})
|
|
81
|
-
this.elements.backdrop?.addEventListener('keydown', (event) => {
|
|
82
|
-
if (event.key !== 'Escape') return
|
|
83
|
-
if (this.isModalOpen()) this.closeModal()
|
|
84
|
-
if (this.isRightPanelOpen()) this.closeRightPanel()
|
|
85
|
-
else this.setMainContentInteractivity(true)
|
|
86
|
-
event.preventDefault()
|
|
87
|
-
})
|
|
88
|
-
this.setInteractivity(this.elements?.modal, false)
|
|
89
|
-
this.setInteractivity(this.elements?.rightPanel, false)
|
|
90
|
-
this.setInteractivity(this.elements?.bottomDialog, false)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Setup the overlay layout elements.
|
|
95
|
-
* @returns the content for the modal, rightPanel and bottomDialog.
|
|
96
|
-
*/
|
|
97
|
-
// @ts-ignore: this should actually be like Kotlin's "internal", i.e. private to any user of the lib, but public to the lib itself.
|
|
98
|
-
private useOverlays() {
|
|
99
|
-
useLayoutEffect(() => {
|
|
100
|
-
if (!this.elements) this.setupElements()
|
|
101
|
-
}, [])
|
|
102
|
-
const [modal, setModal] = useState<ReactElement | undefined>()
|
|
103
|
-
const [rightPanel, setRightPanel] = useState<ReactElement | undefined>()
|
|
104
|
-
const [bottomDialog, setBottomDialog] = useState<ReactElement | undefined>()
|
|
105
|
-
this.setContent.modal = setModal
|
|
106
|
-
this.setContent.rightPanel = setRightPanel
|
|
107
|
-
this.setContent.bottomDialog = setBottomDialog
|
|
108
|
-
return { modal, rightPanel, bottomDialog }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Enables or disables the interactivity of an element.
|
|
113
|
-
* @param element the element to have its interactivity changed.
|
|
114
|
-
* @param interactive false to disable interactivity, true to enable.
|
|
115
|
-
*/
|
|
116
|
-
private setInteractivity(element: HTMLElement | null | undefined, interactive: boolean) {
|
|
117
|
-
if (interactive) {
|
|
118
|
-
element?.removeAttribute('inert')
|
|
119
|
-
element?.removeAttribute('aria-hidden')
|
|
120
|
-
} else {
|
|
121
|
-
element?.setAttribute('aria-hidden', '')
|
|
122
|
-
element?.setAttribute('inert', '')
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private setMainContentInteractivity(interactive: boolean) {
|
|
127
|
-
this.setInteractivity(this.elements?.page, interactive)
|
|
128
|
-
this.setInteractivity(this.elements?.header, interactive)
|
|
129
|
-
this.setInteractivity(this.elements?.menu, interactive)
|
|
130
|
-
this.elements?.backdrop?.setAttribute('class', interactive ? '' : 'visible')
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private showOverlay(element: HTMLElement | null | undefined, extraClasses: string[] = [], blockMainContent = true) {
|
|
134
|
-
this.lastActiveElement = document.activeElement
|
|
135
|
-
element?.classList.add('visible', ...extraClasses)
|
|
136
|
-
this.setInteractivity(element, true)
|
|
137
|
-
if (blockMainContent) this.setMainContentInteractivity(false)
|
|
138
|
-
setTimeout(() => focusFirstChild(
|
|
139
|
-
element,
|
|
140
|
-
{ priority: [['input', 'textarea', 'select', 'other', 'button']], ignore: `#${CLOSE_OVERLAY_ID}` },
|
|
141
|
-
), 50)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private hideOverlay(element: HTMLElement | null | undefined) {
|
|
145
|
-
element?.setAttribute('class', '')
|
|
146
|
-
this.setInteractivity(element, false)
|
|
147
|
-
this.setMainContentInteractivity(true)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* @returns true if the modal is currently opened. False otherwise.
|
|
152
|
-
*/
|
|
153
|
-
isModalOpen() {
|
|
154
|
-
return this.elements?.modal?.classList.contains('visible') ?? false
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* @returns true if the right panel is currently opened. False otherwise.
|
|
159
|
-
*/
|
|
160
|
-
isRightPanelOpen() {
|
|
161
|
-
return this.elements?.rightPanel?.classList.contains('visible') ?? false
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* @returns true if the bottom dialog is currently opened. False otherwise.
|
|
166
|
-
*/
|
|
167
|
-
isBottomDialogOpen() {
|
|
168
|
-
return this.elements?.bottomDialog?.classList.contains('visible') ?? false
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Opens a modal with custom content.
|
|
173
|
-
*
|
|
174
|
-
* Attention: the modal state must be declared within the modal. If the state is declared outside the modal, its content won't be updated
|
|
175
|
-
* accordingly. To force an update of an outside state, you need to call `showCustomModal` again with the new state value.
|
|
176
|
-
*
|
|
177
|
-
* @param content a react element with the modal content.
|
|
178
|
-
* @param options the modal options {@link CustomModalOptions}.
|
|
179
|
-
*/
|
|
180
|
-
showCustomModal(content: React.ReactElement, { size = 'medium', onClose }: CustomModalOptions = {}) {
|
|
181
|
-
if (!this.elements?.modal) throw new ElementNotFound('modal', elementIds.modal)
|
|
182
|
-
if (!this.setContent.modal) throw new LayoutError('unable to show modal, because it has not been setup yet.')
|
|
183
|
-
this.onModalClose = onClose
|
|
184
|
-
this.setContent.modal(content)
|
|
185
|
-
this.showOverlay(this.elements.modal, [size])
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Opens a modal.
|
|
190
|
-
*
|
|
191
|
-
* Attention: the modal state must be declared within the modal. If the state is declared outside the modal, its content won't be updated
|
|
192
|
-
* accordingly. To force an update of an outside state, you need to call `showModal` again with the new state value.
|
|
193
|
-
*
|
|
194
|
-
* @param options the modal options: {@link OverlayContentProps} & { size: {@link ModalSize} }.
|
|
195
|
-
*/
|
|
196
|
-
showModal({ size, ...props }: OverlayContentProps & { size?: ModalSize }) {
|
|
197
|
-
this.showCustomModal(<OverlayContent {...props} onClose={() => this.closeModal()} type="modal" />, { size, onClose: props.onClose })
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private showDialog(options: DialogOptions): Promise<boolean> {
|
|
201
|
-
let dialogResult = false
|
|
202
|
-
return new Promise((resolve, reject) => {
|
|
203
|
-
try {
|
|
204
|
-
this.showCustomModal(
|
|
205
|
-
<Dialog
|
|
206
|
-
{...options}
|
|
207
|
-
onCancel={() => this.closeModal()}
|
|
208
|
-
onConfirm={() => {
|
|
209
|
-
dialogResult = true
|
|
210
|
-
this.closeModal()
|
|
211
|
-
}}
|
|
212
|
-
/>,
|
|
213
|
-
{ size: 'small', onClose: () => resolve(dialogResult) },
|
|
214
|
-
)
|
|
215
|
-
} catch (error) {
|
|
216
|
-
reject(error)
|
|
217
|
-
}
|
|
218
|
-
})
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Shows a confirmation dialog and returns a promise that resolves as soon as the dialog is closed. The result of the promise is true if
|
|
223
|
-
* the user confirms and false otherwise.
|
|
224
|
-
*
|
|
225
|
-
* If you need the user to type something to confirm the action, use the property `validate` in the options parameter.
|
|
226
|
-
* @param options the dialog options: {@link DialogOptions}.
|
|
227
|
-
* @returns a promise that resolves with the user's answer.
|
|
228
|
-
*/
|
|
229
|
-
confirm({ confirm, cancel, ...options }: DialogOptions): Promise<boolean> {
|
|
230
|
-
const t = getDictionary()
|
|
231
|
-
return this.showDialog({ ...options, confirm: confirm || t.confirm, cancel: cancel || t.cancel })
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Shows an alert dialog and returns a promise that resolves as soon as the dialog is closed.
|
|
236
|
-
*
|
|
237
|
-
* @param options the dialog options: {@link AlertOptions}.
|
|
238
|
-
* @returns a promise that resolves to undefined as soon as the dialog is closed.
|
|
239
|
-
*/
|
|
240
|
-
async alert({ confirm, showButton = true, ...options }: AlertOptions): Promise<void> {
|
|
241
|
-
const t = getDictionary()
|
|
242
|
-
await this.showDialog({ ...options, confirm: showButton ? (confirm || t.confirm) : undefined })
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Shows a message at the bottom of the window and asks the user to confirm or decline it. The return value is a promise that resolves as
|
|
247
|
-
* soon as the user presses one of the buttons. The result of the promise is true if the user confirms and false otherwise.
|
|
248
|
-
*
|
|
249
|
-
* Differently than `confirm` and `alert`, this message can only be closed if the user clicks one of the buttons or `closeBottomDialog`
|
|
250
|
-
* is called.
|
|
251
|
-
*
|
|
252
|
-
* @param options the dialog options: {@link BottomDialogOptions}.
|
|
253
|
-
* @returns a promise that resolves with the user's answer.
|
|
254
|
-
*/
|
|
255
|
-
showBottomDialog({ children, cancel, confirm }: BottomDialogOptions): Promise<boolean> {
|
|
256
|
-
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog', elementIds.bottomDialog)
|
|
257
|
-
if (!this.setContent.bottomDialog) throw new LayoutError('unable to show bottom dialog, because it has not been setup yet.')
|
|
258
|
-
return new Promise((resolve) => {
|
|
259
|
-
this.setContent.bottomDialog?.(
|
|
260
|
-
<>
|
|
261
|
-
{children}
|
|
262
|
-
<div className="btn-group">
|
|
263
|
-
{cancel && <Button onClick={() => resolve(false)} colorScheme="light" appearance="outlined">{cancel}</Button>}
|
|
264
|
-
{confirm && <Button onClick={() => resolve(true)} colorScheme="light">{confirm}</Button>}
|
|
265
|
-
</div>
|
|
266
|
-
</>,
|
|
267
|
-
)
|
|
268
|
-
this.showOverlay(this.elements?.bottomDialog, undefined, false)
|
|
269
|
-
})
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Opens a right panel with custom content.
|
|
274
|
-
*
|
|
275
|
-
* Attention: the right panel state must be declared within the right panel. If the state is declared outside the right panel, its content
|
|
276
|
-
* won't be updated accordingly. To force an update of an outside state, you need to call `showCustomRightPanel` again with the new state
|
|
277
|
-
* value.
|
|
278
|
-
*
|
|
279
|
-
* @param content a react element with the modal content.
|
|
280
|
-
* @param options the modal options {@link CustomModalOptions}.
|
|
281
|
-
*/
|
|
282
|
-
showCustomRightPanel(content: ReactElement, { size = 'medium', onClose }: CustomRightPanelOptions = {}) {
|
|
283
|
-
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay', elementIds.rightPanel)
|
|
284
|
-
if (!this.setContent.rightPanel) throw new LayoutError('unable to show right panel overlay, because it has not been setup yet.')
|
|
285
|
-
this.onModalClose = onClose
|
|
286
|
-
this.setContent.rightPanel(content)
|
|
287
|
-
this.elements?.rightPanel.classList.add(size)
|
|
288
|
-
setTimeout(() => {
|
|
289
|
-
this.showOverlay(this.elements?.rightPanel)
|
|
290
|
-
})
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Opens a right panel.
|
|
295
|
-
*
|
|
296
|
-
* Attention: the right panel state must be declared within the right panel. If the state is declared outside the right panel, its content
|
|
297
|
-
* won't be updated accordingly. To force an update of an outside state, you need to call `showRightPanel` again with the new state value.
|
|
298
|
-
*
|
|
299
|
-
* @param options the modal options: {@link OverlayContentProps} & { size: {@link ModalSize} }.
|
|
300
|
-
*/
|
|
301
|
-
showRightPanel({ size, ...props }: OverlayContentProps & { size?: OverlaySize }) {
|
|
302
|
-
this.showCustomRightPanel(
|
|
303
|
-
<OverlayContent {...props} onClose={() => this.closeRightPanel()} type="panel" />,
|
|
304
|
-
{ size, onClose: props.onClose },
|
|
305
|
-
)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/*
|
|
309
|
-
* Focus the element that had focus before the last overlay was opened. If the element is not visible anymore, another one that makes
|
|
310
|
-
* sense (accessibility-wise) is focused.
|
|
311
|
-
*/
|
|
312
|
-
private focusLastActiveElement() {
|
|
313
|
-
focusAccessibleElement(this.lastActiveElement)
|
|
314
|
-
this.lastActiveElement = null
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Closes the modal if it's open.
|
|
319
|
-
* @param runCloseListener whether or not to run the function `onClose` passed to `showModal` or `showCustomModal`. Defaults to true.
|
|
320
|
-
*/
|
|
321
|
-
closeModal(runCloseListener = true) {
|
|
322
|
-
this.elements?.modal?.classList.remove('visible')
|
|
323
|
-
this.elements?.backdrop?.setAttribute('class', '')
|
|
324
|
-
if (runCloseListener && this.onModalClose) {
|
|
325
|
-
const onClose = this.onModalClose
|
|
326
|
-
// setting it to undefined before running it prevents nested calls to closeModal from generating infinite loops.
|
|
327
|
-
this.onModalClose = undefined
|
|
328
|
-
onClose()
|
|
329
|
-
}
|
|
330
|
-
const animationMS = parseFloat(valueOfLayoutVar('--modal-animation-duration')) * 1000
|
|
331
|
-
setTimeout(
|
|
332
|
-
() => {
|
|
333
|
-
if (this.elements?.backdrop?.classList.contains('visible')) {
|
|
334
|
-
// eslint-disable-next-line no-console
|
|
335
|
-
console.warn(multipleCallsWarning('modal', animationMS))
|
|
336
|
-
this.elements?.modal?.classList.remove('visible')
|
|
337
|
-
}
|
|
338
|
-
if (this.setContent.modal) this.setContent.modal(undefined)
|
|
339
|
-
this.hideOverlay(this.elements?.modal)
|
|
340
|
-
this.focusLastActiveElement()
|
|
341
|
-
},
|
|
342
|
-
animationMS,
|
|
343
|
-
)
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Closes the right panel if it's open.
|
|
348
|
-
* @param runCloseListener whether or not to run the function `onClose` passed to `showRightPanel` or `showCustomRightPanel`. Defaults to
|
|
349
|
-
* true.
|
|
350
|
-
*/
|
|
351
|
-
closeRightPanel(runCloseListener = true) {
|
|
352
|
-
this.elements?.rightPanel?.classList.remove('visible')
|
|
353
|
-
this.elements?.backdrop?.setAttribute('class', '')
|
|
354
|
-
if (runCloseListener && this.onModalClose) {
|
|
355
|
-
const onClose = this.onModalClose
|
|
356
|
-
// setting it to undefined before running it prevents nested calls to closeRightPanel from generating infinite loops.
|
|
357
|
-
this.onModalClose = undefined
|
|
358
|
-
onClose()
|
|
359
|
-
}
|
|
360
|
-
const animationMS = parseFloat(valueOfLayoutVar('--right-panel-animation-duration')) * 1000
|
|
361
|
-
setTimeout(
|
|
362
|
-
() => {
|
|
363
|
-
if (this.elements?.backdrop?.classList.contains('visible')) {
|
|
364
|
-
// eslint-disable-next-line no-console
|
|
365
|
-
console.warn(multipleCallsWarning('rightPanel', animationMS))
|
|
366
|
-
this.elements?.rightPanel?.classList.remove('visible')
|
|
367
|
-
}
|
|
368
|
-
if (this.setContent.rightPanel) this.setContent.rightPanel(undefined)
|
|
369
|
-
this.hideOverlay(this.elements?.rightPanel)
|
|
370
|
-
this.focusLastActiveElement()
|
|
371
|
-
},
|
|
372
|
-
animationMS,
|
|
373
|
-
)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Closes the bottom dialog if it's open.
|
|
378
|
-
*/
|
|
379
|
-
closeBottomDialog() {
|
|
380
|
-
this.hideOverlay(this.elements?.bottomDialog)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Verifies if the HTML element passed as parameter is inside the modal.
|
|
385
|
-
* @param element the HTML element to check.
|
|
386
|
-
* @returns true if `element` is inside the modal; false otherwise.
|
|
387
|
-
*/
|
|
388
|
-
isInsideModal(element: HTMLElement) {
|
|
389
|
-
return !!this.elements?.modal?.contains(element)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Verifies if the HTML element passed as parameter is inside the right panel.
|
|
394
|
-
* @param element the HTML element to check.
|
|
395
|
-
* @returns true if `element` is inside the right panel; false otherwise.
|
|
396
|
-
*/
|
|
397
|
-
isInsideRightPanel(element: HTMLElement) {
|
|
398
|
-
return !!this.elements?.rightPanel?.contains(element)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Shows a new toaster on the top right corner of the layout.
|
|
403
|
-
* @example
|
|
404
|
-
* ```
|
|
405
|
-
* overlay.showToaster({ title: 'Welcome', message: 'Hello World' })
|
|
406
|
-
* overlay.showToaster({
|
|
407
|
-
* title: 'Welcome',
|
|
408
|
-
* message: 'Hello World',
|
|
409
|
-
* actions: [
|
|
410
|
-
* { label: 'Got it!' },
|
|
411
|
-
* {
|
|
412
|
-
* label: 'Tell me more',
|
|
413
|
-
* closeOnClick: false,
|
|
414
|
-
* onClick: (event) => {
|
|
415
|
-
* // do something...
|
|
416
|
-
* },
|
|
417
|
-
* },
|
|
418
|
-
* ]
|
|
419
|
-
* })
|
|
420
|
-
* ```
|
|
421
|
-
* @param options the options for the toaster: {@link DefaultToasterOptions}.
|
|
422
|
-
* @returns the toaster's id.
|
|
423
|
-
*/
|
|
424
|
-
showToaster(defaultToasterConfig: DefaultToasterOptions): number | string
|
|
425
|
-
/**
|
|
426
|
-
* Shows a fully customized toaster on the top right corner of the layout.
|
|
427
|
-
* @example
|
|
428
|
-
* ```
|
|
429
|
-
* overlay.showToaster({
|
|
430
|
-
* custom: true,
|
|
431
|
-
* message: <MyCustomToasterContent />,
|
|
432
|
-
* closeButton: <MyCustomCloseButton />,
|
|
433
|
-
* })
|
|
434
|
-
* ```
|
|
435
|
-
* @param options the options for the toaster: {@link CustomToasterOptions}.
|
|
436
|
-
* @returns the toaster's id.
|
|
437
|
-
*/
|
|
438
|
-
showToaster(customToasterConfig: CustomToasterOptions): number | string
|
|
439
|
-
/**
|
|
440
|
-
* Shows the message passed as parameter in a new toaster on the top right corner of the layout.
|
|
441
|
-
* @example
|
|
442
|
-
* ```
|
|
443
|
-
* overlay.showToaster('Hello World!')
|
|
444
|
-
* overlay.showToaster(<p>Hello World</p>)
|
|
445
|
-
* ```
|
|
446
|
-
* @param message the message to show, can be either a string or React Element.
|
|
447
|
-
* @returns the toaster's id.
|
|
448
|
-
*/
|
|
449
|
-
showToaster(message: React.ReactNode): number | string
|
|
450
|
-
showToaster(options: any): number | string {
|
|
451
|
-
return showReactToaster(options)
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Closes the toaster with the specified id.
|
|
456
|
-
* @param id the id of the toaster to close.
|
|
457
|
-
*/
|
|
458
|
-
closeToaster = closeReactToaster
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Manages overlay components of the layout like: modal, rightPanel, bottomDialog and toaster.
|
|
463
|
-
*/
|
|
464
|
-
export const overlay = new LayoutOverlayManager()
|
|
1
|
+
/* eslint-disable react-hooks/rules-of-hooks */
|
|
2
|
+
|
|
3
|
+
import { Button } from '@citric/core'
|
|
4
|
+
import { focusAccessibleElement, focusFirstChild } from '@stack-spot/portal-components'
|
|
5
|
+
import { ReactElement, useLayoutEffect, useState } from 'react'
|
|
6
|
+
import { Dialog, DialogOptions } from './components/Dialog'
|
|
7
|
+
import { CLOSE_OVERLAY_ID, OverlayContent, OverlayContentProps } from './components/OverlayContent'
|
|
8
|
+
import { getDictionary } from './dictionary'
|
|
9
|
+
import { LayoutElements, elementIds, getLayoutElements } from './elements'
|
|
10
|
+
import { ElementNotFound, LayoutError } from './errors'
|
|
11
|
+
import { CustomToasterOptions, DefaultToasterOptions, closeReactToaster, showToaster as showReactToaster } from './toaster'
|
|
12
|
+
import { valueOfLayoutVar } from './utils'
|
|
13
|
+
|
|
14
|
+
interface AlertOptions extends Omit<DialogOptions, 'cancel'> {
|
|
15
|
+
/**
|
|
16
|
+
* Whether or not to show an "ok" button. If false, the dialog can still be closed through the close button, by clicking outside it or by
|
|
17
|
+
* pressing ESC.
|
|
18
|
+
*/
|
|
19
|
+
showButton?: boolean,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type BottomDialogOptions = Omit<DialogOptions, 'title'>
|
|
23
|
+
type OverlaySize = 'small' | 'medium' | 'large'
|
|
24
|
+
type ModalSize = 'fit-content' | OverlaySize
|
|
25
|
+
type SetContentFn = ((content: ReactElement | undefined) => void) | undefined
|
|
26
|
+
|
|
27
|
+
interface OverlayContentSetter {
|
|
28
|
+
modal?: SetContentFn,
|
|
29
|
+
rightPanel?: SetContentFn,
|
|
30
|
+
bottomDialog?: SetContentFn,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CustomModalOptions {
|
|
34
|
+
/**
|
|
35
|
+
* The size of the modal.
|
|
36
|
+
*/
|
|
37
|
+
size?: ModalSize,
|
|
38
|
+
/**
|
|
39
|
+
* A function to call when the modal closes.
|
|
40
|
+
*/
|
|
41
|
+
onClose?: () => void,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CustomRightPanelOptions {
|
|
45
|
+
/**
|
|
46
|
+
* The size of the right panel.
|
|
47
|
+
*/
|
|
48
|
+
size?: OverlaySize,
|
|
49
|
+
/**
|
|
50
|
+
* A function to call when the right panel closes.
|
|
51
|
+
*/
|
|
52
|
+
onClose?: () => void,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function multipleCallsWarning(type: 'modal' | 'rightPanel', timeMS: number) {
|
|
56
|
+
return `
|
|
57
|
+
Attempted to show a modal or rightPanel while a ${type} was still being closed. Closing a ${type} takes only ${timeMS}ms, so this action
|
|
58
|
+
is unlikely to have been triggered by the user and may point to errors in your code. Please check.\nTip: showModal and showRightPanel
|
|
59
|
+
are sideEffects and should never be called during the render of a component. Try to use "useEffect" or link it to an event, like
|
|
60
|
+
"onClick".
|
|
61
|
+
`.replace(/\s*\n\s+/g, ' ')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class LayoutOverlayManager {
|
|
65
|
+
static readonly instance?: LayoutOverlayManager
|
|
66
|
+
private setContent: OverlayContentSetter = {}
|
|
67
|
+
private elements?: LayoutElements
|
|
68
|
+
private onModalClose?: () => void
|
|
69
|
+
/**
|
|
70
|
+
* Last element with focus before an overlay is shown.
|
|
71
|
+
*/
|
|
72
|
+
private lastActiveElement: Element | null = null
|
|
73
|
+
|
|
74
|
+
private setupElements() {
|
|
75
|
+
this.elements = getLayoutElements()
|
|
76
|
+
this.elements.backdrop?.addEventListener('mousedown', (event) => {
|
|
77
|
+
if (this.isModalOpen()) !this.elements?.modal?.contains?.(event.target as Node) && this.closeModal()
|
|
78
|
+
else if (this.isRightPanelOpen()) !this.elements?.rightPanel?.contains?.(event.target as Node) && this.closeRightPanel()
|
|
79
|
+
else this.setMainContentInteractivity(true)
|
|
80
|
+
})
|
|
81
|
+
this.elements.backdrop?.addEventListener('keydown', (event) => {
|
|
82
|
+
if (event.key !== 'Escape') return
|
|
83
|
+
if (this.isModalOpen()) this.closeModal()
|
|
84
|
+
if (this.isRightPanelOpen()) this.closeRightPanel()
|
|
85
|
+
else this.setMainContentInteractivity(true)
|
|
86
|
+
event.preventDefault()
|
|
87
|
+
})
|
|
88
|
+
this.setInteractivity(this.elements?.modal, false)
|
|
89
|
+
this.setInteractivity(this.elements?.rightPanel, false)
|
|
90
|
+
this.setInteractivity(this.elements?.bottomDialog, false)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Setup the overlay layout elements.
|
|
95
|
+
* @returns the content for the modal, rightPanel and bottomDialog.
|
|
96
|
+
*/
|
|
97
|
+
// @ts-ignore: this should actually be like Kotlin's "internal", i.e. private to any user of the lib, but public to the lib itself.
|
|
98
|
+
private useOverlays() {
|
|
99
|
+
useLayoutEffect(() => {
|
|
100
|
+
if (!this.elements) this.setupElements()
|
|
101
|
+
}, [])
|
|
102
|
+
const [modal, setModal] = useState<ReactElement | undefined>()
|
|
103
|
+
const [rightPanel, setRightPanel] = useState<ReactElement | undefined>()
|
|
104
|
+
const [bottomDialog, setBottomDialog] = useState<ReactElement | undefined>()
|
|
105
|
+
this.setContent.modal = setModal
|
|
106
|
+
this.setContent.rightPanel = setRightPanel
|
|
107
|
+
this.setContent.bottomDialog = setBottomDialog
|
|
108
|
+
return { modal, rightPanel, bottomDialog }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Enables or disables the interactivity of an element.
|
|
113
|
+
* @param element the element to have its interactivity changed.
|
|
114
|
+
* @param interactive false to disable interactivity, true to enable.
|
|
115
|
+
*/
|
|
116
|
+
private setInteractivity(element: HTMLElement | null | undefined, interactive: boolean) {
|
|
117
|
+
if (interactive) {
|
|
118
|
+
element?.removeAttribute('inert')
|
|
119
|
+
element?.removeAttribute('aria-hidden')
|
|
120
|
+
} else {
|
|
121
|
+
element?.setAttribute('aria-hidden', '')
|
|
122
|
+
element?.setAttribute('inert', '')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private setMainContentInteractivity(interactive: boolean) {
|
|
127
|
+
this.setInteractivity(this.elements?.page, interactive)
|
|
128
|
+
this.setInteractivity(this.elements?.header, interactive)
|
|
129
|
+
this.setInteractivity(this.elements?.menu, interactive)
|
|
130
|
+
this.elements?.backdrop?.setAttribute('class', interactive ? '' : 'visible')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private showOverlay(element: HTMLElement | null | undefined, extraClasses: string[] = [], blockMainContent = true) {
|
|
134
|
+
this.lastActiveElement = document.activeElement
|
|
135
|
+
element?.classList.add('visible', ...extraClasses)
|
|
136
|
+
this.setInteractivity(element, true)
|
|
137
|
+
if (blockMainContent) this.setMainContentInteractivity(false)
|
|
138
|
+
setTimeout(() => focusFirstChild(
|
|
139
|
+
element,
|
|
140
|
+
{ priority: [['input', 'textarea', 'select', 'other', 'button']], ignore: `#${CLOSE_OVERLAY_ID}` },
|
|
141
|
+
), 50)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private hideOverlay(element: HTMLElement | null | undefined) {
|
|
145
|
+
element?.setAttribute('class', '')
|
|
146
|
+
this.setInteractivity(element, false)
|
|
147
|
+
this.setMainContentInteractivity(true)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @returns true if the modal is currently opened. False otherwise.
|
|
152
|
+
*/
|
|
153
|
+
isModalOpen() {
|
|
154
|
+
return this.elements?.modal?.classList.contains('visible') ?? false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @returns true if the right panel is currently opened. False otherwise.
|
|
159
|
+
*/
|
|
160
|
+
isRightPanelOpen() {
|
|
161
|
+
return this.elements?.rightPanel?.classList.contains('visible') ?? false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @returns true if the bottom dialog is currently opened. False otherwise.
|
|
166
|
+
*/
|
|
167
|
+
isBottomDialogOpen() {
|
|
168
|
+
return this.elements?.bottomDialog?.classList.contains('visible') ?? false
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Opens a modal with custom content.
|
|
173
|
+
*
|
|
174
|
+
* Attention: the modal state must be declared within the modal. If the state is declared outside the modal, its content won't be updated
|
|
175
|
+
* accordingly. To force an update of an outside state, you need to call `showCustomModal` again with the new state value.
|
|
176
|
+
*
|
|
177
|
+
* @param content a react element with the modal content.
|
|
178
|
+
* @param options the modal options {@link CustomModalOptions}.
|
|
179
|
+
*/
|
|
180
|
+
showCustomModal(content: React.ReactElement, { size = 'medium', onClose }: CustomModalOptions = {}) {
|
|
181
|
+
if (!this.elements?.modal) throw new ElementNotFound('modal', elementIds.modal)
|
|
182
|
+
if (!this.setContent.modal) throw new LayoutError('unable to show modal, because it has not been setup yet.')
|
|
183
|
+
this.onModalClose = onClose
|
|
184
|
+
this.setContent.modal(content)
|
|
185
|
+
this.showOverlay(this.elements.modal, [size])
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Opens a modal.
|
|
190
|
+
*
|
|
191
|
+
* Attention: the modal state must be declared within the modal. If the state is declared outside the modal, its content won't be updated
|
|
192
|
+
* accordingly. To force an update of an outside state, you need to call `showModal` again with the new state value.
|
|
193
|
+
*
|
|
194
|
+
* @param options the modal options: {@link OverlayContentProps} & { size: {@link ModalSize} }.
|
|
195
|
+
*/
|
|
196
|
+
showModal({ size, ...props }: OverlayContentProps & { size?: ModalSize }) {
|
|
197
|
+
this.showCustomModal(<OverlayContent {...props} onClose={() => this.closeModal()} type="modal" />, { size, onClose: props.onClose })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private showDialog(options: DialogOptions): Promise<boolean> {
|
|
201
|
+
let dialogResult = false
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
try {
|
|
204
|
+
this.showCustomModal(
|
|
205
|
+
<Dialog
|
|
206
|
+
{...options}
|
|
207
|
+
onCancel={() => this.closeModal()}
|
|
208
|
+
onConfirm={() => {
|
|
209
|
+
dialogResult = true
|
|
210
|
+
this.closeModal()
|
|
211
|
+
}}
|
|
212
|
+
/>,
|
|
213
|
+
{ size: 'small', onClose: () => resolve(dialogResult) },
|
|
214
|
+
)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
reject(error)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Shows a confirmation dialog and returns a promise that resolves as soon as the dialog is closed. The result of the promise is true if
|
|
223
|
+
* the user confirms and false otherwise.
|
|
224
|
+
*
|
|
225
|
+
* If you need the user to type something to confirm the action, use the property `validate` in the options parameter.
|
|
226
|
+
* @param options the dialog options: {@link DialogOptions}.
|
|
227
|
+
* @returns a promise that resolves with the user's answer.
|
|
228
|
+
*/
|
|
229
|
+
confirm({ confirm, cancel, ...options }: DialogOptions): Promise<boolean> {
|
|
230
|
+
const t = getDictionary()
|
|
231
|
+
return this.showDialog({ ...options, confirm: confirm || t.confirm, cancel: cancel || t.cancel })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Shows an alert dialog and returns a promise that resolves as soon as the dialog is closed.
|
|
236
|
+
*
|
|
237
|
+
* @param options the dialog options: {@link AlertOptions}.
|
|
238
|
+
* @returns a promise that resolves to undefined as soon as the dialog is closed.
|
|
239
|
+
*/
|
|
240
|
+
async alert({ confirm, showButton = true, ...options }: AlertOptions): Promise<void> {
|
|
241
|
+
const t = getDictionary()
|
|
242
|
+
await this.showDialog({ ...options, confirm: showButton ? (confirm || t.confirm) : undefined })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Shows a message at the bottom of the window and asks the user to confirm or decline it. The return value is a promise that resolves as
|
|
247
|
+
* soon as the user presses one of the buttons. The result of the promise is true if the user confirms and false otherwise.
|
|
248
|
+
*
|
|
249
|
+
* Differently than `confirm` and `alert`, this message can only be closed if the user clicks one of the buttons or `closeBottomDialog`
|
|
250
|
+
* is called.
|
|
251
|
+
*
|
|
252
|
+
* @param options the dialog options: {@link BottomDialogOptions}.
|
|
253
|
+
* @returns a promise that resolves with the user's answer.
|
|
254
|
+
*/
|
|
255
|
+
showBottomDialog({ children, cancel, confirm }: BottomDialogOptions): Promise<boolean> {
|
|
256
|
+
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog', elementIds.bottomDialog)
|
|
257
|
+
if (!this.setContent.bottomDialog) throw new LayoutError('unable to show bottom dialog, because it has not been setup yet.')
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
this.setContent.bottomDialog?.(
|
|
260
|
+
<>
|
|
261
|
+
{children}
|
|
262
|
+
<div className="btn-group">
|
|
263
|
+
{cancel && <Button onClick={() => resolve(false)} colorScheme="light" appearance="outlined">{cancel}</Button>}
|
|
264
|
+
{confirm && <Button onClick={() => resolve(true)} colorScheme="light">{confirm}</Button>}
|
|
265
|
+
</div>
|
|
266
|
+
</>,
|
|
267
|
+
)
|
|
268
|
+
this.showOverlay(this.elements?.bottomDialog, undefined, false)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Opens a right panel with custom content.
|
|
274
|
+
*
|
|
275
|
+
* Attention: the right panel state must be declared within the right panel. If the state is declared outside the right panel, its content
|
|
276
|
+
* won't be updated accordingly. To force an update of an outside state, you need to call `showCustomRightPanel` again with the new state
|
|
277
|
+
* value.
|
|
278
|
+
*
|
|
279
|
+
* @param content a react element with the modal content.
|
|
280
|
+
* @param options the modal options {@link CustomModalOptions}.
|
|
281
|
+
*/
|
|
282
|
+
showCustomRightPanel(content: ReactElement, { size = 'medium', onClose }: CustomRightPanelOptions = {}) {
|
|
283
|
+
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay', elementIds.rightPanel)
|
|
284
|
+
if (!this.setContent.rightPanel) throw new LayoutError('unable to show right panel overlay, because it has not been setup yet.')
|
|
285
|
+
this.onModalClose = onClose
|
|
286
|
+
this.setContent.rightPanel(content)
|
|
287
|
+
this.elements?.rightPanel.classList.add(size)
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
this.showOverlay(this.elements?.rightPanel)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Opens a right panel.
|
|
295
|
+
*
|
|
296
|
+
* Attention: the right panel state must be declared within the right panel. If the state is declared outside the right panel, its content
|
|
297
|
+
* won't be updated accordingly. To force an update of an outside state, you need to call `showRightPanel` again with the new state value.
|
|
298
|
+
*
|
|
299
|
+
* @param options the modal options: {@link OverlayContentProps} & { size: {@link ModalSize} }.
|
|
300
|
+
*/
|
|
301
|
+
showRightPanel({ size, ...props }: OverlayContentProps & { size?: OverlaySize }) {
|
|
302
|
+
this.showCustomRightPanel(
|
|
303
|
+
<OverlayContent {...props} onClose={() => this.closeRightPanel()} type="panel" />,
|
|
304
|
+
{ size, onClose: props.onClose },
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
* Focus the element that had focus before the last overlay was opened. If the element is not visible anymore, another one that makes
|
|
310
|
+
* sense (accessibility-wise) is focused.
|
|
311
|
+
*/
|
|
312
|
+
private focusLastActiveElement() {
|
|
313
|
+
focusAccessibleElement(this.lastActiveElement)
|
|
314
|
+
this.lastActiveElement = null
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Closes the modal if it's open.
|
|
319
|
+
* @param runCloseListener whether or not to run the function `onClose` passed to `showModal` or `showCustomModal`. Defaults to true.
|
|
320
|
+
*/
|
|
321
|
+
closeModal(runCloseListener = true) {
|
|
322
|
+
this.elements?.modal?.classList.remove('visible')
|
|
323
|
+
this.elements?.backdrop?.setAttribute('class', '')
|
|
324
|
+
if (runCloseListener && this.onModalClose) {
|
|
325
|
+
const onClose = this.onModalClose
|
|
326
|
+
// setting it to undefined before running it prevents nested calls to closeModal from generating infinite loops.
|
|
327
|
+
this.onModalClose = undefined
|
|
328
|
+
onClose()
|
|
329
|
+
}
|
|
330
|
+
const animationMS = parseFloat(valueOfLayoutVar('--modal-animation-duration')) * 1000
|
|
331
|
+
setTimeout(
|
|
332
|
+
() => {
|
|
333
|
+
if (this.elements?.backdrop?.classList.contains('visible')) {
|
|
334
|
+
// eslint-disable-next-line no-console
|
|
335
|
+
console.warn(multipleCallsWarning('modal', animationMS))
|
|
336
|
+
this.elements?.modal?.classList.remove('visible')
|
|
337
|
+
}
|
|
338
|
+
if (this.setContent.modal) this.setContent.modal(undefined)
|
|
339
|
+
this.hideOverlay(this.elements?.modal)
|
|
340
|
+
this.focusLastActiveElement()
|
|
341
|
+
},
|
|
342
|
+
animationMS,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Closes the right panel if it's open.
|
|
348
|
+
* @param runCloseListener whether or not to run the function `onClose` passed to `showRightPanel` or `showCustomRightPanel`. Defaults to
|
|
349
|
+
* true.
|
|
350
|
+
*/
|
|
351
|
+
closeRightPanel(runCloseListener = true) {
|
|
352
|
+
this.elements?.rightPanel?.classList.remove('visible')
|
|
353
|
+
this.elements?.backdrop?.setAttribute('class', '')
|
|
354
|
+
if (runCloseListener && this.onModalClose) {
|
|
355
|
+
const onClose = this.onModalClose
|
|
356
|
+
// setting it to undefined before running it prevents nested calls to closeRightPanel from generating infinite loops.
|
|
357
|
+
this.onModalClose = undefined
|
|
358
|
+
onClose()
|
|
359
|
+
}
|
|
360
|
+
const animationMS = parseFloat(valueOfLayoutVar('--right-panel-animation-duration')) * 1000
|
|
361
|
+
setTimeout(
|
|
362
|
+
() => {
|
|
363
|
+
if (this.elements?.backdrop?.classList.contains('visible')) {
|
|
364
|
+
// eslint-disable-next-line no-console
|
|
365
|
+
console.warn(multipleCallsWarning('rightPanel', animationMS))
|
|
366
|
+
this.elements?.rightPanel?.classList.remove('visible')
|
|
367
|
+
}
|
|
368
|
+
if (this.setContent.rightPanel) this.setContent.rightPanel(undefined)
|
|
369
|
+
this.hideOverlay(this.elements?.rightPanel)
|
|
370
|
+
this.focusLastActiveElement()
|
|
371
|
+
},
|
|
372
|
+
animationMS,
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Closes the bottom dialog if it's open.
|
|
378
|
+
*/
|
|
379
|
+
closeBottomDialog() {
|
|
380
|
+
this.hideOverlay(this.elements?.bottomDialog)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Verifies if the HTML element passed as parameter is inside the modal.
|
|
385
|
+
* @param element the HTML element to check.
|
|
386
|
+
* @returns true if `element` is inside the modal; false otherwise.
|
|
387
|
+
*/
|
|
388
|
+
isInsideModal(element: HTMLElement) {
|
|
389
|
+
return !!this.elements?.modal?.contains(element)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Verifies if the HTML element passed as parameter is inside the right panel.
|
|
394
|
+
* @param element the HTML element to check.
|
|
395
|
+
* @returns true if `element` is inside the right panel; false otherwise.
|
|
396
|
+
*/
|
|
397
|
+
isInsideRightPanel(element: HTMLElement) {
|
|
398
|
+
return !!this.elements?.rightPanel?.contains(element)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Shows a new toaster on the top right corner of the layout.
|
|
403
|
+
* @example
|
|
404
|
+
* ```
|
|
405
|
+
* overlay.showToaster({ title: 'Welcome', message: 'Hello World' })
|
|
406
|
+
* overlay.showToaster({
|
|
407
|
+
* title: 'Welcome',
|
|
408
|
+
* message: 'Hello World',
|
|
409
|
+
* actions: [
|
|
410
|
+
* { label: 'Got it!' },
|
|
411
|
+
* {
|
|
412
|
+
* label: 'Tell me more',
|
|
413
|
+
* closeOnClick: false,
|
|
414
|
+
* onClick: (event) => {
|
|
415
|
+
* // do something...
|
|
416
|
+
* },
|
|
417
|
+
* },
|
|
418
|
+
* ]
|
|
419
|
+
* })
|
|
420
|
+
* ```
|
|
421
|
+
* @param options the options for the toaster: {@link DefaultToasterOptions}.
|
|
422
|
+
* @returns the toaster's id.
|
|
423
|
+
*/
|
|
424
|
+
showToaster(defaultToasterConfig: DefaultToasterOptions): number | string
|
|
425
|
+
/**
|
|
426
|
+
* Shows a fully customized toaster on the top right corner of the layout.
|
|
427
|
+
* @example
|
|
428
|
+
* ```
|
|
429
|
+
* overlay.showToaster({
|
|
430
|
+
* custom: true,
|
|
431
|
+
* message: <MyCustomToasterContent />,
|
|
432
|
+
* closeButton: <MyCustomCloseButton />,
|
|
433
|
+
* })
|
|
434
|
+
* ```
|
|
435
|
+
* @param options the options for the toaster: {@link CustomToasterOptions}.
|
|
436
|
+
* @returns the toaster's id.
|
|
437
|
+
*/
|
|
438
|
+
showToaster(customToasterConfig: CustomToasterOptions): number | string
|
|
439
|
+
/**
|
|
440
|
+
* Shows the message passed as parameter in a new toaster on the top right corner of the layout.
|
|
441
|
+
* @example
|
|
442
|
+
* ```
|
|
443
|
+
* overlay.showToaster('Hello World!')
|
|
444
|
+
* overlay.showToaster(<p>Hello World</p>)
|
|
445
|
+
* ```
|
|
446
|
+
* @param message the message to show, can be either a string or React Element.
|
|
447
|
+
* @returns the toaster's id.
|
|
448
|
+
*/
|
|
449
|
+
showToaster(message: React.ReactNode): number | string
|
|
450
|
+
showToaster(options: any): number | string {
|
|
451
|
+
return showReactToaster(options)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Closes the toaster with the specified id.
|
|
456
|
+
* @param id the id of the toaster to close.
|
|
457
|
+
*/
|
|
458
|
+
closeToaster = closeReactToaster
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Manages overlay components of the layout like: modal, rightPanel, bottomDialog and toaster.
|
|
463
|
+
*/
|
|
464
|
+
export const overlay = new LayoutOverlayManager()
|