@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.
Files changed (65) hide show
  1. package/dist/Layout.d.ts +19 -5
  2. package/dist/Layout.d.ts.map +1 -1
  3. package/dist/Layout.js +13 -3
  4. package/dist/Layout.js.map +1 -1
  5. package/dist/LayoutOverlayManager.d.ts +22 -7
  6. package/dist/LayoutOverlayManager.d.ts.map +1 -1
  7. package/dist/LayoutOverlayManager.js +36 -26
  8. package/dist/LayoutOverlayManager.js.map +1 -1
  9. package/dist/components/Dialog.d.ts +9 -1
  10. package/dist/components/Dialog.d.ts.map +1 -1
  11. package/dist/components/Dialog.js +12 -4
  12. package/dist/components/Dialog.js.map +1 -1
  13. package/dist/components/Header.d.ts +2 -1
  14. package/dist/components/Header.d.ts.map +1 -1
  15. package/dist/components/Header.js +1 -1
  16. package/dist/components/Header.js.map +1 -1
  17. package/dist/components/Menu/MenuContent.d.ts +1 -1
  18. package/dist/components/Menu/MenuContent.d.ts.map +1 -1
  19. package/dist/components/Menu/MenuContent.js +46 -12
  20. package/dist/components/Menu/MenuContent.js.map +1 -1
  21. package/dist/components/Menu/MenuSections.d.ts +2 -1
  22. package/dist/components/Menu/MenuSections.d.ts.map +1 -1
  23. package/dist/components/Menu/MenuSections.js +39 -9
  24. package/dist/components/Menu/MenuSections.js.map +1 -1
  25. package/dist/components/Menu/PageSelector.d.ts +1 -1
  26. package/dist/components/Menu/PageSelector.d.ts.map +1 -1
  27. package/dist/components/Menu/PageSelector.js +9 -3
  28. package/dist/components/Menu/PageSelector.js.map +1 -1
  29. package/dist/components/Menu/types.d.ts +47 -7
  30. package/dist/components/Menu/types.d.ts.map +1 -1
  31. package/dist/components/OverlayContent.d.ts.map +1 -1
  32. package/dist/components/OverlayContent.js +8 -2
  33. package/dist/components/OverlayContent.js.map +1 -1
  34. package/dist/components/SelectionList.d.ts +2 -1
  35. package/dist/components/SelectionList.d.ts.map +1 -1
  36. package/dist/components/SelectionList.js +7 -3
  37. package/dist/components/SelectionList.js.map +1 -1
  38. package/dist/components/Toaster.d.ts.map +1 -1
  39. package/dist/components/Toaster.js +5 -1
  40. package/dist/components/Toaster.js.map +1 -1
  41. package/dist/dictionary.d.ts +15 -0
  42. package/dist/dictionary.d.ts.map +1 -0
  43. package/dist/dictionary.js +23 -0
  44. package/dist/dictionary.js.map +1 -0
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/layout.css +38 -10
  50. package/package.json +4 -3
  51. package/src/Layout.tsx +46 -16
  52. package/src/LayoutOverlayManager.tsx +57 -29
  53. package/src/components/Dialog.tsx +38 -7
  54. package/src/components/Header.tsx +3 -2
  55. package/src/components/Menu/MenuContent.tsx +60 -16
  56. package/src/components/Menu/MenuSections.tsx +58 -14
  57. package/src/components/Menu/PageSelector.tsx +25 -12
  58. package/src/components/Menu/types.ts +50 -7
  59. package/src/components/OverlayContent.tsx +19 -13
  60. package/src/components/SelectionList.tsx +9 -3
  61. package/src/components/Toaster.tsx +9 -5
  62. package/src/dictionary.ts +25 -0
  63. package/src/index.ts +2 -0
  64. package/src/layout.css +38 -10
  65. 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: ReactElement,
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: ReactElement,
35
+ children: ReactNode,
23
36
  compactMenu?: boolean,
37
+ errorBoundaries?: ErrorBoundaries,
24
38
  }
25
39
 
26
- export const RawLayout = ({ menuSections, menuContent, header, compactMenu = true, children, className, style }: RawProps) => {
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
- <div id="rightPanel">{rightPanel}</div>
47
- <div id="bottomDialog">{bottomDialog}</div>
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
- export const Layout = ({ menu, header, children, className, style }: Props) => (
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 sections={menu.sections} />}
61
- menuContent={menu.content ? <MenuContent {...menu.content} /> : undefined}
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, Flex } from '@citric/core'
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
- type ModalSize = 'fit-content' | 'small' | 'medium' | 'large'
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: ModalSize = 'medium') {
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.onModalClose = props.onClose
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 = 'OK', cancel = 'Cancel', ...options }: DialogOptions): Promise<boolean> {
110
- return this.showDialog({ ...options, confirm, cancel })
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 = 'OK', showButton = true, ...options }: AlertOptions): Promise<void> {
114
- await this.showDialog({ ...options, confirm: showButton ? confirm : undefined })
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
- <Flex gap>
133
+ this.setContent.bottomDialog?.(
134
+ <>
123
135
  {message}
124
- {cancel && <Button onClick={() => resolve(true)} colorScheme="inverse" appearance="outlined">{cancel}</Button>}
125
- {confirm && <Button onClick={() => resolve(false)} colorScheme="inverse">{confirm}</Button>}
126
- </Flex>,
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.setAttribute('class', 'visible')
151
+ this.elements?.rightPanel.classList.add(size)
152
+ setTimeout(() => this.elements?.rightPanel?.classList?.add('visible'))
137
153
  }
138
154
 
139
- showRightPanel(props: OverlayContentProps) {
140
- const onClose = () => {
141
- this.closeRightPanel()
142
- if (props.onClose) props.onClose()
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?.setAttribute('class', '')
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
- export const Dialog = ({ message, title, subtitle, cancel, confirm, validation, onConfirm, onCancel }: Props) => {
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.label
59
+ const value = typeof validation === 'string' ? validation : validation.value
31
60
  const label = typeof validation === 'object' && validation.label
32
61
  ? validation.label
33
- : `Please, confirm the action by typing "${value}" below:`
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="modal">
45
- {message}
46
- {renderValidation()}
47
- {(cancel || confirm) && <Flex gap justifyContent="end" alignItems="center" sx={{ mt: 6 }}>
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 title="stackspot" style={{ width: 130 }} />}
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={active ? onClick : undefined}
116
- className={listToClass(['action', active ? 'active' : undefined])}
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
- <Text appearance="body2">{label}</Text>
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
- <Text appearance="body2">{label}</Text>
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
- <Text appearance="overheader2" colorScheme="light.700" className="group-title">{label}</Text>
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
- <MenuGroup className="open root no-indentation">{items}</MenuGroup>
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, MenuSectionContent } from './types'
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
- function hideOverlayImmediately() {
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
- function renderSection(
39
- { icon, label, href, onClick, active, content }: MenuSection,
40
- setOverlayContent: (content: MenuSectionContent | undefined) => void,
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
- return !!content && (!active || !isMenuContentVisible())
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
- setOverlayContent(content)
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
- export const MenuSections = ({ sections }: Pick<MenuProps, 'sections'>) => {
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
- const [overlayContent, setOverlayContent] = useState<MenuSectionContent | undefined>()
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(() => sections.map(s => renderSection(s, setOverlayContent)), [sections])
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
- {overlayContent && <div><MenuContent {...overlayContent} /></div>}
144
+ {renderMenuOverlay()}
101
145
  <div className="arrow"></div>
102
146
  </div>
103
147
  </>