@stack-spot/portal-layout 0.0.2 → 0.0.4
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/dist/Layout.d.ts +19 -5
- package/dist/Layout.d.ts.map +1 -1
- package/dist/Layout.js +13 -3
- package/dist/Layout.js.map +1 -1
- package/dist/LayoutOverlayManager.d.ts +22 -7
- package/dist/LayoutOverlayManager.d.ts.map +1 -1
- package/dist/LayoutOverlayManager.js +36 -26
- package/dist/LayoutOverlayManager.js.map +1 -1
- package/dist/components/Dialog.d.ts +9 -1
- package/dist/components/Dialog.d.ts.map +1 -1
- package/dist/components/Dialog.js +12 -4
- package/dist/components/Dialog.js.map +1 -1
- package/dist/components/Header.d.ts +2 -1
- package/dist/components/Header.d.ts.map +1 -1
- package/dist/components/Header.js +1 -1
- package/dist/components/Header.js.map +1 -1
- package/dist/components/Menu/MenuContent.d.ts +1 -1
- package/dist/components/Menu/MenuContent.d.ts.map +1 -1
- package/dist/components/Menu/MenuContent.js +46 -12
- package/dist/components/Menu/MenuContent.js.map +1 -1
- package/dist/components/Menu/MenuSections.d.ts +2 -1
- package/dist/components/Menu/MenuSections.d.ts.map +1 -1
- package/dist/components/Menu/MenuSections.js +39 -9
- package/dist/components/Menu/MenuSections.js.map +1 -1
- package/dist/components/Menu/PageSelector.d.ts +1 -1
- package/dist/components/Menu/PageSelector.d.ts.map +1 -1
- package/dist/components/Menu/PageSelector.js +9 -3
- package/dist/components/Menu/PageSelector.js.map +1 -1
- package/dist/components/Menu/types.d.ts +47 -7
- package/dist/components/Menu/types.d.ts.map +1 -1
- package/dist/components/OverlayContent.d.ts.map +1 -1
- package/dist/components/OverlayContent.js +8 -2
- package/dist/components/OverlayContent.js.map +1 -1
- package/dist/components/SelectionList.d.ts +2 -1
- package/dist/components/SelectionList.d.ts.map +1 -1
- package/dist/components/SelectionList.js +7 -3
- package/dist/components/SelectionList.js.map +1 -1
- package/dist/components/Toaster.d.ts.map +1 -1
- package/dist/components/Toaster.js +5 -1
- package/dist/components/Toaster.js.map +1 -1
- package/dist/dictionary.d.ts +15 -0
- package/dist/dictionary.d.ts.map +1 -0
- package/dist/dictionary.js +23 -0
- package/dist/dictionary.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout.css +38 -10
- package/package.json +4 -3
- package/src/Layout.tsx +46 -16
- package/src/LayoutOverlayManager.tsx +57 -29
- package/src/components/Dialog.tsx +38 -7
- package/src/components/Header.tsx +3 -2
- package/src/components/Menu/MenuContent.tsx +60 -16
- package/src/components/Menu/MenuSections.tsx +58 -14
- package/src/components/Menu/PageSelector.tsx +25 -12
- package/src/components/Menu/types.ts +50 -7
- package/src/components/OverlayContent.tsx +19 -13
- package/src/components/SelectionList.tsx +9 -3
- package/src/components/Toaster.tsx +9 -5
- package/src/dictionary.ts +25 -0
- package/src/index.ts +2 -0
- package/src/layout.css +38 -10
- package/src/citric.fix.d.ts +0 -7
package/src/Layout.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CSSToCitricAdapter, listToClass, WithStyle } from '@stack-spot/portal-theme'
|
|
2
2
|
import '@stack-spot/portal-theme/dist/theme.css'
|
|
3
|
-
import { ReactElement } from 'react'
|
|
3
|
+
import { ComponentClass, ReactElement, ReactNode } from 'react'
|
|
4
4
|
import { Header, HeaderProps } from './components/Header'
|
|
5
5
|
import { MenuContent } from './components/Menu/MenuContent'
|
|
6
6
|
import { MenuSections } from './components/Menu/MenuSections'
|
|
@@ -9,21 +9,37 @@ import { Toaster } from './components/Toaster'
|
|
|
9
9
|
import './layout.css'
|
|
10
10
|
import { overlay } from './LayoutOverlayManager'
|
|
11
11
|
|
|
12
|
+
type ErrorBoundaryComponent = ComponentClass<{ children: ReactNode }>
|
|
13
|
+
|
|
14
|
+
interface ErrorBoundaries {
|
|
15
|
+
page?: ErrorBoundaryComponent,
|
|
16
|
+
menuSections?: ErrorBoundaryComponent,
|
|
17
|
+
menuContent?: ErrorBoundaryComponent,
|
|
18
|
+
modal?: ErrorBoundaryComponent,
|
|
19
|
+
rightPanel?: ErrorBoundaryComponent,
|
|
20
|
+
header?: ErrorBoundaryComponent,
|
|
21
|
+
bottomDialog?: ErrorBoundaryComponent,
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
interface Props extends WithStyle {
|
|
13
25
|
menu: MenuProps,
|
|
14
26
|
header: HeaderProps,
|
|
15
|
-
children:
|
|
27
|
+
children: ReactNode,
|
|
28
|
+
errorBoundaries?: ErrorBoundaries,
|
|
16
29
|
}
|
|
17
30
|
|
|
18
31
|
interface RawProps extends WithStyle {
|
|
19
32
|
menuSections: ReactElement,
|
|
20
33
|
menuContent?: ReactElement,
|
|
21
34
|
header: ReactElement,
|
|
22
|
-
children:
|
|
35
|
+
children: ReactNode,
|
|
23
36
|
compactMenu?: boolean,
|
|
37
|
+
errorBoundaries?: ErrorBoundaries,
|
|
24
38
|
}
|
|
25
39
|
|
|
26
|
-
export const RawLayout = (
|
|
40
|
+
export const RawLayout = (
|
|
41
|
+
{ menuSections, menuContent, header, compactMenu = true, children, errorBoundaries = {}, className, style }: RawProps,
|
|
42
|
+
) => {
|
|
27
43
|
// @ts-ignore
|
|
28
44
|
const { bottomDialog, modal, rightPanel } = overlay.useOverlays()
|
|
29
45
|
const classes = [
|
|
@@ -32,21 +48,26 @@ export const RawLayout = ({ menuSections, menuContent, header, compactMenu = tru
|
|
|
32
48
|
className,
|
|
33
49
|
]
|
|
34
50
|
|
|
51
|
+
function includeErrorBoundary(content: ReactNode, section: keyof ErrorBoundaries) {
|
|
52
|
+
const ErrorBoundary = errorBoundaries[section]
|
|
53
|
+
return ErrorBoundary ? <ErrorBoundary>{content}</ErrorBoundary> : content
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
return (
|
|
36
57
|
<CSSToCitricAdapter>
|
|
37
58
|
<div id="layout" className={listToClass(classes)} style={style}>
|
|
38
|
-
<header id="header">{header}</header>
|
|
39
|
-
<aside id="menu">
|
|
40
|
-
<nav id="menuContent">{menuContent}</nav>
|
|
41
|
-
<nav id="menuSections">{menuSections}</nav>
|
|
42
|
-
</aside>
|
|
43
59
|
<div id="page">
|
|
44
|
-
<article id="content">{children}</article>
|
|
60
|
+
<article id="content">{includeErrorBoundary(children, 'page')}</article>
|
|
45
61
|
</div>
|
|
46
|
-
<
|
|
47
|
-
<
|
|
62
|
+
<header id="header">{includeErrorBoundary(header, 'header')}</header>
|
|
63
|
+
<aside id="menu">
|
|
64
|
+
<nav id="menuContent">{includeErrorBoundary(menuContent, 'menuContent')}</nav>
|
|
65
|
+
<nav id="menuSections">{includeErrorBoundary(menuSections, 'menuSections')}</nav>
|
|
66
|
+
</aside>
|
|
67
|
+
<div id="rightPanel">{includeErrorBoundary(rightPanel, 'rightPanel')}</div>
|
|
68
|
+
<div id="bottomDialog">{includeErrorBoundary(bottomDialog, 'bottomDialog')}</div>
|
|
48
69
|
<div id="backdrop">
|
|
49
|
-
<div id="modal">{modal}</div>
|
|
70
|
+
<div id="modal">{includeErrorBoundary(modal, 'modal')}</div>
|
|
50
71
|
</div>
|
|
51
72
|
<Toaster />
|
|
52
73
|
</div>
|
|
@@ -54,12 +75,21 @@ export const RawLayout = ({ menuSections, menuContent, header, compactMenu = tru
|
|
|
54
75
|
)
|
|
55
76
|
}
|
|
56
77
|
|
|
57
|
-
|
|
78
|
+
const MenuContentRenderer = ({ content }: Required<Pick<Props['menu'], 'content'>>) => {
|
|
79
|
+
const menuContent = typeof content === 'function' ? content() : content
|
|
80
|
+
return <MenuContent {...menuContent} />
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const Layout = ({ menu, header, children, errorBoundaries, className, style }: Props) => (
|
|
58
84
|
<RawLayout
|
|
59
85
|
header={<Header {...header} />}
|
|
60
|
-
menuSections={<MenuSections
|
|
61
|
-
menuContent={menu.content
|
|
86
|
+
menuSections={<MenuSections {...menu} />}
|
|
87
|
+
menuContent={menu.content
|
|
88
|
+
? <MenuContentRenderer key={'contentKey' in menu ? menu.contentKey : undefined} content={menu.content} />
|
|
89
|
+
: undefined
|
|
90
|
+
}
|
|
62
91
|
compactMenu={menu.compact}
|
|
92
|
+
errorBoundaries={errorBoundaries}
|
|
63
93
|
className={className}
|
|
64
94
|
style={style}
|
|
65
95
|
>
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* eslint-disable react-hooks/rules-of-hooks */
|
|
2
2
|
|
|
3
|
-
import { Button
|
|
3
|
+
import { Button } from '@citric/core'
|
|
4
4
|
import { ReactElement, useLayoutEffect, useState } from 'react'
|
|
5
5
|
import { Dialog, DialogOptions } from './components/Dialog'
|
|
6
6
|
import { OverlayContent, OverlayContentProps } from './components/OverlayContent'
|
|
7
|
+
import { getDictionary } from './dictionary'
|
|
7
8
|
import { ElementNotFound, LayoutError } from './errors'
|
|
8
9
|
import { showToaster as showReactToaster } from './toaster'
|
|
9
10
|
import { valueOfLayoutVar } from './utils'
|
|
@@ -13,6 +14,8 @@ interface AlertOptions extends Omit<DialogOptions, 'cancel'> {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
type BottomDialogOptions = Omit<DialogOptions, 'title'>
|
|
17
|
+
type OverlaySize = 'small' | 'medium' | 'large'
|
|
18
|
+
type ModalSize = 'fit-content' | OverlaySize
|
|
16
19
|
|
|
17
20
|
interface LayoutElements {
|
|
18
21
|
backdrop: HTMLElement | null,
|
|
@@ -29,7 +32,15 @@ interface OverlayContentSetter {
|
|
|
29
32
|
bottomDialog?: SetContentFn,
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
interface CustomModalOptions {
|
|
36
|
+
size?: ModalSize,
|
|
37
|
+
onClose?: () => void,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CustomRightPanelOptions {
|
|
41
|
+
size?: OverlaySize,
|
|
42
|
+
onClose?: () => void,
|
|
43
|
+
}
|
|
33
44
|
|
|
34
45
|
const BACKDROP_ID = 'backdrop'
|
|
35
46
|
const MODAL_ID = 'modal'
|
|
@@ -71,23 +82,22 @@ class LayoutOverlayManager {
|
|
|
71
82
|
return { modal, rightPanel, bottomDialog }
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
showCustomModal(content: ReactElement, size
|
|
85
|
+
showCustomModal(content: React.ReactElement, { size = 'medium', onClose }: CustomModalOptions = {}) {
|
|
75
86
|
if (!this.elements?.modal) throw new ElementNotFound('modal', MODAL_ID)
|
|
76
87
|
if (!this.setContent.modal) throw new LayoutError('unable to show modal, because it has not been setup yet.')
|
|
88
|
+
this.onModalClose = onClose
|
|
77
89
|
this.setContent.modal(content)
|
|
78
90
|
this.elements.backdrop?.setAttribute('class', 'visible')
|
|
79
91
|
this.elements.modal.setAttribute('class', `visible ${size}`)
|
|
80
92
|
}
|
|
81
93
|
|
|
82
|
-
showModal(props: OverlayContentProps) {
|
|
83
|
-
this.
|
|
84
|
-
this.showCustomModal(<OverlayContent {...props} onClose={() => this.closeModal()} type="modal" />)
|
|
94
|
+
showModal({ size, ...props }: OverlayContentProps & { size?: ModalSize }) {
|
|
95
|
+
this.showCustomModal(<OverlayContent {...props} onClose={() => this.closeModal()} type="modal" />, { size, onClose: props.onClose })
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
private showDialog(options: DialogOptions): Promise<boolean> {
|
|
88
99
|
let dialogResult = false
|
|
89
100
|
return new Promise((resolve, reject) => {
|
|
90
|
-
this.onModalClose = () => resolve(dialogResult)
|
|
91
101
|
try {
|
|
92
102
|
this.showCustomModal(
|
|
93
103
|
<Dialog
|
|
@@ -98,7 +108,7 @@ class LayoutOverlayManager {
|
|
|
98
108
|
this.closeModal()
|
|
99
109
|
}}
|
|
100
110
|
/>,
|
|
101
|
-
'small',
|
|
111
|
+
{ size: 'small', onClose: () => resolve(dialogResult) },
|
|
102
112
|
)
|
|
103
113
|
} catch (error) {
|
|
104
114
|
reject(error)
|
|
@@ -106,48 +116,53 @@ class LayoutOverlayManager {
|
|
|
106
116
|
})
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
confirm({ confirm
|
|
110
|
-
|
|
119
|
+
confirm({ confirm, cancel, ...options }: DialogOptions): Promise<boolean> {
|
|
120
|
+
const t = getDictionary()
|
|
121
|
+
return this.showDialog({ ...options, confirm: confirm || t.confirm, cancel: cancel || t.cancel })
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
async alert({ confirm
|
|
114
|
-
|
|
124
|
+
async alert({ confirm, showButton = true, ...options }: AlertOptions): Promise<void> {
|
|
125
|
+
const t = getDictionary()
|
|
126
|
+
await this.showDialog({ ...options, confirm: showButton ? (confirm || t.confirm) : undefined })
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
showBottomDialog({ message, cancel, confirm }: BottomDialogOptions): Promise<boolean> {
|
|
118
130
|
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog', BOTTOM_DIALOG_ID)
|
|
119
131
|
if (!this.setContent.bottomDialog) throw new LayoutError('unable to show bottom dialog, because it has not been setup yet.')
|
|
120
132
|
return new Promise((resolve) => {
|
|
121
|
-
this.setContent.bottomDialog
|
|
122
|
-
|
|
133
|
+
this.setContent.bottomDialog?.(
|
|
134
|
+
<>
|
|
123
135
|
{message}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
<div className="btn-group">
|
|
137
|
+
{cancel && <Button onClick={() => resolve(false)} colorScheme="light" appearance="outlined">{cancel}</Button>}
|
|
138
|
+
{confirm && <Button onClick={() => resolve(true)} colorScheme="light">{confirm}</Button>}
|
|
139
|
+
</div>
|
|
140
|
+
</>,
|
|
127
141
|
)
|
|
128
142
|
this.elements?.bottomDialog?.setAttribute('class', 'visible')
|
|
129
143
|
})
|
|
130
144
|
}
|
|
131
145
|
|
|
132
|
-
showCustomRightPanel(content: ReactElement) {
|
|
146
|
+
showCustomRightPanel(content: ReactElement, { size = 'medium', onClose }: CustomRightPanelOptions = {}) {
|
|
133
147
|
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay', RIGHT_PANEL_ID)
|
|
134
148
|
if (!this.setContent.rightPanel) throw new LayoutError('unable to show right panel overlay, because it has not been setup yet.')
|
|
149
|
+
this.onModalClose = onClose
|
|
135
150
|
this.setContent.rightPanel(content)
|
|
136
|
-
this.elements?.rightPanel.
|
|
151
|
+
this.elements?.rightPanel.classList.add(size)
|
|
152
|
+
setTimeout(() => this.elements?.rightPanel?.classList?.add('visible'))
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
showRightPanel(props: OverlayContentProps) {
|
|
140
|
-
|
|
141
|
-
this.closeRightPanel()
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
this.showCustomRightPanel(<OverlayContent {...props} onClose={onClose} type="panel" />)
|
|
155
|
+
showRightPanel({ size, ...props }: OverlayContentProps & { size?: OverlaySize }) {
|
|
156
|
+
this.showCustomRightPanel(
|
|
157
|
+
<OverlayContent {...props} onClose={() => this.closeRightPanel()} type="panel" />,
|
|
158
|
+
{ size, onClose: props.onClose },
|
|
159
|
+
)
|
|
145
160
|
}
|
|
146
161
|
|
|
147
|
-
closeModal() {
|
|
162
|
+
closeModal(runCloseListener = true) {
|
|
148
163
|
this.elements?.modal?.classList.remove('visible')
|
|
149
164
|
this.elements?.backdrop?.setAttribute('class', '')
|
|
150
|
-
if (this.onModalClose) {
|
|
165
|
+
if (runCloseListener && this.onModalClose) {
|
|
151
166
|
this.onModalClose()
|
|
152
167
|
this.onModalClose = undefined
|
|
153
168
|
}
|
|
@@ -160,11 +175,16 @@ class LayoutOverlayManager {
|
|
|
160
175
|
)
|
|
161
176
|
}
|
|
162
177
|
|
|
163
|
-
closeRightPanel() {
|
|
164
|
-
this.elements?.rightPanel?.
|
|
178
|
+
closeRightPanel(runCloseListener = true) {
|
|
179
|
+
this.elements?.rightPanel?.classList.remove('visible')
|
|
180
|
+
if (runCloseListener && this.onModalClose) {
|
|
181
|
+
this.onModalClose()
|
|
182
|
+
this.onModalClose = undefined
|
|
183
|
+
}
|
|
165
184
|
setTimeout(
|
|
166
185
|
() => {
|
|
167
186
|
if (this.setContent.rightPanel) this.setContent.rightPanel(undefined)
|
|
187
|
+
this.elements?.rightPanel?.removeAttribute('class')
|
|
168
188
|
},
|
|
169
189
|
parseFloat(valueOfLayoutVar('--right-panel-animation-duration')) * 1000,
|
|
170
190
|
)
|
|
@@ -174,6 +194,14 @@ class LayoutOverlayManager {
|
|
|
174
194
|
this.elements?.bottomDialog?.setAttribute('class', '')
|
|
175
195
|
}
|
|
176
196
|
|
|
197
|
+
isInsideModal(element: HTMLElement) {
|
|
198
|
+
return !!this.elements?.modal?.contains(element)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
isInsideRightPanel(element: HTMLElement) {
|
|
202
|
+
return !!this.elements?.rightPanel?.contains(element)
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
showToaster = showReactToaster
|
|
178
206
|
}
|
|
179
207
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Button, Flex, Input, Text } from '@citric/core'
|
|
2
|
+
import { interpolate } from '@stack-spot/portal-translate'
|
|
2
3
|
import { ReactNode, useState } from 'react'
|
|
4
|
+
import { useDictionary } from '../dictionary'
|
|
3
5
|
import { OverlayContent } from './OverlayContent'
|
|
4
6
|
|
|
5
7
|
interface Validation {
|
|
@@ -15,6 +17,14 @@ export interface DialogOptions {
|
|
|
15
17
|
confirm?: string,
|
|
16
18
|
cancel?: string,
|
|
17
19
|
validation?: false | string | Validation,
|
|
20
|
+
/**
|
|
21
|
+
* @default modal
|
|
22
|
+
*/
|
|
23
|
+
type?: 'modal' | 'panel',
|
|
24
|
+
/**
|
|
25
|
+
* @default right if type is "panel", "right" otherwise.
|
|
26
|
+
*/
|
|
27
|
+
buttonPlacement?: 'left' | 'center' | 'right',
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
interface Props extends DialogOptions {
|
|
@@ -22,15 +32,34 @@ interface Props extends DialogOptions {
|
|
|
22
32
|
onCancel: () => void,
|
|
23
33
|
}
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
const justifyButtons: Record<Required<DialogOptions>['buttonPlacement'], React.CSSProperties['justifyContent']> = {
|
|
36
|
+
center: 'center',
|
|
37
|
+
left: 'start',
|
|
38
|
+
right: 'end',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const Dialog = ({
|
|
42
|
+
message,
|
|
43
|
+
title,
|
|
44
|
+
subtitle,
|
|
45
|
+
cancel,
|
|
46
|
+
confirm,
|
|
47
|
+
validation,
|
|
48
|
+
onConfirm,
|
|
49
|
+
onCancel,
|
|
50
|
+
type = 'modal',
|
|
51
|
+
buttonPlacement = type === 'panel' ? 'left' : 'right',
|
|
52
|
+
}: Props,
|
|
53
|
+
) => {
|
|
54
|
+
const t = useDictionary()
|
|
26
55
|
const [enabled, setEnabled] = useState(!validation)
|
|
27
56
|
|
|
28
57
|
function renderValidation() {
|
|
29
58
|
if (!validation) return null
|
|
30
|
-
const value = typeof validation === 'string' ? validation : validation.
|
|
59
|
+
const value = typeof validation === 'string' ? validation : validation.value
|
|
31
60
|
const label = typeof validation === 'object' && validation.label
|
|
32
61
|
? validation.label
|
|
33
|
-
:
|
|
62
|
+
: interpolate(t.validationLabel, value)
|
|
34
63
|
const placeholder = typeof validation === 'object' ? validation.placeholder : undefined
|
|
35
64
|
return (
|
|
36
65
|
<div style={{ margin: '16px 0' }}>
|
|
@@ -41,10 +70,12 @@ export const Dialog = ({ message, title, subtitle, cancel, confirm, validation,
|
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
return (
|
|
44
|
-
<OverlayContent title={title} subtitle={subtitle} onClose={onCancel} type=
|
|
45
|
-
{
|
|
46
|
-
|
|
47
|
-
|
|
73
|
+
<OverlayContent title={title} subtitle={subtitle} onClose={onCancel} type={type}>
|
|
74
|
+
<Flex flexDirection="column" flex={1}>
|
|
75
|
+
{message}
|
|
76
|
+
{renderValidation()}
|
|
77
|
+
</Flex>
|
|
78
|
+
{(cancel || confirm) && <Flex gap justifyContent={justifyButtons[buttonPlacement]} alignItems="center" sx={{ mt: 6 }}>
|
|
48
79
|
{cancel && <Button appearance="outlined" colorScheme="inverse" onClick={onCancel}>{cancel}</Button>}
|
|
49
80
|
{confirm && <Button colorScheme="primary" onClick={onConfirm} disabled={!enabled}>
|
|
50
81
|
{confirm}
|
|
@@ -6,6 +6,7 @@ import { UserMenu } from './UserMenu'
|
|
|
6
6
|
|
|
7
7
|
export interface HeaderProps {
|
|
8
8
|
logo?: ReactNode,
|
|
9
|
+
logoHref?: string,
|
|
9
10
|
userName?: string,
|
|
10
11
|
email?: string,
|
|
11
12
|
options?: SelectionListProps['items'],
|
|
@@ -13,9 +14,9 @@ export interface HeaderProps {
|
|
|
13
14
|
right?: ReactNode,
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export const Header = ({ logo, center, right, userName, email, options }: HeaderProps) => (
|
|
17
|
+
export const Header = ({ logo, logoHref, center, right, userName, email, options }: HeaderProps) => (
|
|
17
18
|
<>
|
|
18
|
-
{logo ?? <StackspotLogo
|
|
19
|
+
<a href={logoHref} title="Home">{logo ?? <StackspotLogo style={{ width: 130 }} />}</a>
|
|
19
20
|
<Flex flex={1}>{center}</Flex>
|
|
20
21
|
{right}
|
|
21
22
|
{userName && <UserMenu userName={userName} email={email} options={options} />}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
-
import { IconBox, Text } from '@citric/core'
|
|
2
|
+
import { Flex, IconBox, Text } from '@citric/core'
|
|
3
3
|
import { ArrowLeft, ChevronDown } from '@citric/icons'
|
|
4
|
+
import { LoadingCircular } from '@citric/ui'
|
|
4
5
|
import { listToClass, theme } from '@stack-spot/portal-theme'
|
|
5
6
|
import { useMemo, useState } from 'react'
|
|
6
7
|
import { styled } from 'styled-components'
|
|
8
|
+
import { hideOverlayImmediately } from './MenuSections'
|
|
7
9
|
import { PageSelector } from './PageSelector'
|
|
8
10
|
import { MENU_CONTENT_ITEM_PADDING as ITEM_PADDING, MENU_CONTENT_PADDING as PADDING } from './constants'
|
|
9
11
|
import { ItemGroup, MenuAction, MenuItem, MenuSectionContent } from './types'
|
|
@@ -26,16 +28,34 @@ const MenuGroup = styled.ul`
|
|
|
26
28
|
padding: 0;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
.item-row {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: row;
|
|
34
|
+
gap: 8px;
|
|
35
|
+
align-items: center;
|
|
36
|
+
|
|
37
|
+
.label {
|
|
38
|
+
flex: 1;
|
|
39
|
+
&.hidden, &.ellipsis {
|
|
40
|
+
white-space: nowrap;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
}
|
|
43
|
+
&.ellipsis {
|
|
44
|
+
text-overflow: ellipsis;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
li a {
|
|
30
50
|
position: relative;
|
|
31
51
|
height: 0;
|
|
32
52
|
overflow: hidden;
|
|
33
|
-
display: flex;
|
|
34
|
-
align-items: center;
|
|
53
|
+
/* display: flex; */
|
|
54
|
+
/* align-items: center; */
|
|
35
55
|
transition: height 0.3s, background-color 0.2s;
|
|
36
56
|
margin: 0 ${PADDING - ITEM_PADDING}px;
|
|
37
57
|
border-radius: 4px;
|
|
38
|
-
justify-content: space-between;
|
|
58
|
+
/* justify-content: space-between; */
|
|
39
59
|
padding: 0 ${ITEM_PADDING}px;
|
|
40
60
|
|
|
41
61
|
&:hover {
|
|
@@ -109,24 +129,32 @@ const Title = styled.header`
|
|
|
109
129
|
margin: ${PADDING}px 0 24px ${PADDING}px;
|
|
110
130
|
`
|
|
111
131
|
|
|
112
|
-
const ActionItem = ({ label, onClick, href, active }: MenuAction) => (
|
|
132
|
+
const ActionItem = ({ label, onClick, href, active, icon, badge, overflow = 'wrap' }: MenuAction) => (
|
|
113
133
|
<a
|
|
114
134
|
href={active ? undefined : href}
|
|
115
|
-
onClick={
|
|
116
|
-
|
|
135
|
+
onClick={() => {
|
|
136
|
+
if (active) return
|
|
137
|
+
if (onClick) onClick()
|
|
138
|
+
hideOverlayImmediately()
|
|
139
|
+
}}
|
|
140
|
+
className={listToClass(['action', 'item-row', active ? 'active' : undefined])}
|
|
117
141
|
>
|
|
118
|
-
|
|
142
|
+
{icon}
|
|
143
|
+
<Text appearance="body2" className={`label ${overflow}`}>{label}</Text>
|
|
144
|
+
{badge}
|
|
119
145
|
</a>
|
|
120
146
|
)
|
|
121
147
|
|
|
122
|
-
const CollapsibleGroupItem = ({ label, open: initiallyOpened, children }: ItemGroup) => {
|
|
148
|
+
const CollapsibleGroupItem = ({ label, open: initiallyOpened, children, icon, badge, overflow = 'wrap' }: ItemGroup) => {
|
|
123
149
|
const [open, setOpen] = useState(initiallyOpened ?? children?.some(c => 'active' in c && c.active) ?? false)
|
|
124
150
|
const items = useMemo(() => children?.map(renderOption), [children])
|
|
125
151
|
|
|
126
152
|
return (
|
|
127
153
|
<>
|
|
128
|
-
<a onClick={() => setOpen(!open)}>
|
|
129
|
-
|
|
154
|
+
<a onClick={() => setOpen(!open)} className="item-row">
|
|
155
|
+
{icon}
|
|
156
|
+
<Text appearance="body2" className={`label ${overflow}`}>{label}</Text>
|
|
157
|
+
{badge}
|
|
130
158
|
<IconBox><ChevronDown className={listToClass(['chevron', open ? 'open' : ''])} /></IconBox>
|
|
131
159
|
</a>
|
|
132
160
|
<MenuGroup className={open ? 'open' : undefined}>{items}</MenuGroup>
|
|
@@ -134,12 +162,16 @@ const CollapsibleGroupItem = ({ label, open: initiallyOpened, children }: ItemGr
|
|
|
134
162
|
)
|
|
135
163
|
}
|
|
136
164
|
|
|
137
|
-
const RootGroupItem = ({ label, children }: ItemGroup) => {
|
|
138
|
-
const items = useMemo(() => children?.map(renderOption), [children])
|
|
165
|
+
const RootGroupItem = ({ label, children, icon, badge, overflow = 'wrap' }: ItemGroup) => {
|
|
166
|
+
const items = useMemo(() => children?.filter(i => !i.hidden).map(renderOption), [children])
|
|
139
167
|
|
|
140
168
|
return (
|
|
141
169
|
<>
|
|
142
|
-
<
|
|
170
|
+
<div className="item-row">
|
|
171
|
+
{icon}
|
|
172
|
+
<Text appearance="overheader2" colorScheme="light.700" className={`group-title label ${overflow}`}>{label}</Text>
|
|
173
|
+
{badge}
|
|
174
|
+
</div>
|
|
143
175
|
<MenuGroup className="open no-indentation">{items}</MenuGroup>
|
|
144
176
|
</>
|
|
145
177
|
)
|
|
@@ -153,9 +185,21 @@ function renderOption({ root, ...option }: MenuItem & { root?: boolean }) {
|
|
|
153
185
|
return <li key={option.label}>{'children' in option ? <GroupItem root={root} {...option} /> : <ActionItem {...option} />}</li>
|
|
154
186
|
}
|
|
155
187
|
|
|
156
|
-
export const MenuContent = ({ pageSelector, goBack, title, subtitle, options = [] }: MenuSectionContent) => {
|
|
188
|
+
export const MenuContent = ({ pageSelector, goBack, title, subtitle, options = [], loading, error }: MenuSectionContent) => {
|
|
157
189
|
const items = useMemo(() => options.filter(o => !o.hidden).map(o => renderOption({ ...o, root: true })), [options])
|
|
158
190
|
|
|
191
|
+
function renderContent() {
|
|
192
|
+
if (loading) {
|
|
193
|
+
return (
|
|
194
|
+
<Flex justifyContent="center" alignItems="center" flex={1} sx={{ padding: '40px' }}>
|
|
195
|
+
<LoadingCircular />
|
|
196
|
+
</Flex>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
if (error) return <Text colorScheme="danger">{error}</Text>
|
|
200
|
+
return <MenuGroup className="open root no-indentation">{items}</MenuGroup>
|
|
201
|
+
}
|
|
202
|
+
|
|
159
203
|
return (
|
|
160
204
|
<>
|
|
161
205
|
{goBack && (
|
|
@@ -173,7 +217,7 @@ export const MenuContent = ({ pageSelector, goBack, title, subtitle, options = [
|
|
|
173
217
|
</Title>
|
|
174
218
|
)}
|
|
175
219
|
{pageSelector && <PageSelector {...pageSelector} />}
|
|
176
|
-
|
|
220
|
+
{renderContent()}
|
|
177
221
|
</>
|
|
178
222
|
)
|
|
179
223
|
}
|
|
@@ -3,7 +3,7 @@ import { IconBox, Text } from '@citric/core'
|
|
|
3
3
|
import { ChevronLeft, Menu as MenuIcon } from '@citric/icons'
|
|
4
4
|
import { useCallback, useMemo, useState } from 'react'
|
|
5
5
|
import { MenuContent } from './MenuContent'
|
|
6
|
-
import { MenuProps, MenuSection
|
|
6
|
+
import { MenuProps, MenuSection } from './types'
|
|
7
7
|
|
|
8
8
|
const ARROW_HEIGHT = 24
|
|
9
9
|
const HIDE_OVERLAY_DELAY_MS = 400
|
|
@@ -16,7 +16,8 @@ function hideOverlay() {
|
|
|
16
16
|
hideOverlayTask = window.setTimeout(hideOverlayImmediately, HIDE_OVERLAY_DELAY_MS)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
20
|
+
export function hideOverlayImmediately() {
|
|
20
21
|
document.getElementById(MENU_OVERLAY_ID)?.classList.remove('visible')
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -35,19 +36,36 @@ function isMenuContentVisible() {
|
|
|
35
36
|
return !!document.getElementById('layout')?.classList?.contains('menu-content-visible')
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
const Section = ({
|
|
40
|
+
icon,
|
|
41
|
+
label,
|
|
42
|
+
href,
|
|
43
|
+
onClick,
|
|
44
|
+
active,
|
|
45
|
+
content,
|
|
46
|
+
onOpen,
|
|
47
|
+
setCurrentOverlay,
|
|
48
|
+
id,
|
|
49
|
+
hasContent,
|
|
50
|
+
}: MenuSection & { id: number, setCurrentOverlay: (id: number | undefined) => void, hasContent: boolean }) => {
|
|
51
|
+
const contentToRender = typeof content === 'function' ? content() : content
|
|
52
|
+
|
|
42
53
|
function shouldShowOverlay() {
|
|
43
|
-
|
|
54
|
+
/* The overlay should appear if:
|
|
55
|
+
* 1. The section has some content to render OR:
|
|
56
|
+
* 1.1 The section is active and there is a contextual menu for the active page.
|
|
57
|
+
* 2. The section is inactive OR:
|
|
58
|
+
* 2.1. The contextual menu is hidden.
|
|
59
|
+
*/
|
|
60
|
+
return (!!contentToRender || (hasContent && active)) && (!active || !isMenuContentVisible())
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
function showOverlayAndFixArrowPosition(event: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
|
47
64
|
if (!shouldShowOverlay()) return
|
|
65
|
+
onOpen?.()
|
|
48
66
|
const rect = (event.target as HTMLElement)?.getBoundingClientRect()
|
|
49
67
|
const arrow: HTMLElement | null = document.querySelector(`#${MENU_OVERLAY_ID} .arrow`)
|
|
50
|
-
|
|
68
|
+
setCurrentOverlay(id)
|
|
51
69
|
showOverlay()
|
|
52
70
|
if (rect && arrow) {
|
|
53
71
|
arrow.style.top = `${rect.top + rect.height / 2 - ARROW_HEIGHT / 2}px`
|
|
@@ -74,7 +92,14 @@ function renderSection(
|
|
|
74
92
|
)
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
|
|
95
|
+
const OverlayRenderer = ({ content }: Pick<MenuSection, 'content'>) => {
|
|
96
|
+
const data = typeof content === 'function' ? content() : content
|
|
97
|
+
return <div><MenuContent {...data} /></div>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const MenuSections = ({ sections, ...props }: MenuProps) => {
|
|
101
|
+
// this is a mock state only used to force an update on the component.
|
|
102
|
+
const [_, setUpdate] = useState(0)
|
|
78
103
|
const toggleMenu = useCallback(() => {
|
|
79
104
|
const layout = document.getElementById('layout')
|
|
80
105
|
if (!layout) return
|
|
@@ -83,21 +108,40 @@ export const MenuSections = ({ sections }: Pick<MenuProps, 'sections'>) => {
|
|
|
83
108
|
} else {
|
|
84
109
|
layout.classList.add('menu-content-visible')
|
|
85
110
|
}
|
|
111
|
+
setUpdate(current => current + 1)
|
|
86
112
|
}, [])
|
|
87
|
-
|
|
113
|
+
// the current overlay showing, when the user hovers the section icon. This is the index of the item in the sections array.
|
|
114
|
+
const [currentOverlay, setCurrentOverlay] = useState<number | undefined>()
|
|
88
115
|
|
|
89
|
-
const sectionItems = useMemo(
|
|
116
|
+
const sectionItems = useMemo(
|
|
117
|
+
() => sections.map((s, i) => <Section key={i} id={i} {...s} setCurrentOverlay={setCurrentOverlay} hasContent={!!props.content} />),
|
|
118
|
+
[sections],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/* This function renders the section preview in the overlay in normal circumstances. If the menu is hidden and the section is active,
|
|
122
|
+
instead of rendering the section preview, it will render the actual menu content, which would be invisible otherwise.
|
|
123
|
+
Below, the key is of extreme importance. It ensures React will consider every section content to be an entirely different
|
|
124
|
+
component. Without this, React would see the content changing every time a new section is hovered. Since the content might be a
|
|
125
|
+
hook, this would cause some serious problems. */
|
|
126
|
+
function renderMenuOverlay() {
|
|
127
|
+
if (currentOverlay === undefined) return null
|
|
128
|
+
const shouldRenderMenuContentInstead = !isMenuContentVisible() && sections[currentOverlay].active && !!props.content
|
|
129
|
+
return shouldRenderMenuContentInstead
|
|
130
|
+
? <OverlayRenderer key={'contentKey' in props ? props.contentKey : undefined} content={props.content} />
|
|
131
|
+
: <OverlayRenderer key={currentOverlay} content={sections[currentOverlay].content} />
|
|
132
|
+
}
|
|
133
|
+
|
|
90
134
|
return (
|
|
91
135
|
<>
|
|
92
136
|
<ul>{sectionItems}</ul>
|
|
93
|
-
<button className="toggle" onClick={toggleMenu} title="Toggle menu panel visibility">
|
|
137
|
+
{!!props.content && <button className="toggle" onClick={toggleMenu} title="Toggle menu panel visibility">
|
|
94
138
|
<IconBox>
|
|
95
139
|
<MenuIcon className="expand" />
|
|
96
140
|
<ChevronLeft className="collapse" />
|
|
97
141
|
</IconBox>
|
|
98
|
-
</button>
|
|
142
|
+
</button>}
|
|
99
143
|
<div id="menuContentOverlay" onMouseEnter={showOverlay} onMouseLeave={hideOverlay}>
|
|
100
|
-
{
|
|
144
|
+
{renderMenuOverlay()}
|
|
101
145
|
<div className="arrow"></div>
|
|
102
146
|
</div>
|
|
103
147
|
</>
|