@stack-spot/portal-layout 0.0.53 → 0.0.54
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 +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/SelectionList.d.ts +1 -1
- package/dist/components/SelectionList.js +54 -54
- package/dist/components/SelectionList.js.map +1 -1
- package/dist/components/Toaster.d.ts +1 -1
- package/dist/components/Toaster.js +1 -1
- package/dist/components/UserMenu.d.ts +1 -1
- package/dist/components/UserMenu.js +41 -41
- package/dist/components/error/ErrorBoundary.d.ts +1 -1
- package/dist/components/error/ErrorBoundary.js +1 -1
- package/dist/components/error/ErrorFeedback.d.ts +1 -1
- package/dist/components/error/ErrorFeedback.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 +10 -12
- package/dist/components/menu/MenuContent.d.ts.map +1 -1
- package/dist/components/menu/MenuContent.js +146 -146
- 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 +65 -65
- package/dist/components/menu/PageSelector.js.map +1 -1
- package/dist/components/menu/use-check-text-overflow.js.map +1 -1
- package/dist/layout-context.d.ts +1 -1
- package/dist/layout-context.js +1 -1
- package/dist/layout.css +467 -466
- package/dist/svg/AI.d.ts +1 -1
- package/dist/svg/AI.js +1 -1
- package/dist/svg/EDP.d.ts +1 -1
- package/dist/svg/EDP.js +1 -1
- package/dist/svg/Forbidden.d.ts +1 -1
- package/dist/svg/Forbidden.js +1 -1
- package/dist/svg/HUB.d.ts +1 -1
- package/dist/svg/HUB.js +1 -1
- package/dist/svg/Logo.d.ts +1 -1
- package/dist/svg/Logo.js +1 -1
- package/dist/svg/NotFound.d.ts +1 -1
- package/dist/svg/NotFound.js +1 -1
- package/dist/svg/ServerError.d.ts +1 -1
- package/dist/svg/ServerError.js +1 -1
- package/dist/svg/Unauthenticated.d.ts +1 -1
- package/dist/svg/Unauthenticated.js +1 -1
- package/dist/toaster.js +1 -1
- package/dist/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/Layout.tsx +106 -106
- package/src/LayoutOverlayManager.tsx +273 -273
- package/src/components/Dialog.tsx +93 -93
- package/src/components/Header.tsx +34 -34
- package/src/components/OverlayContent.tsx +58 -58
- package/src/components/PortalSwitcher.tsx +147 -147
- package/src/components/SelectionList.tsx +272 -272
- package/src/components/Toaster.tsx +16 -16
- package/src/components/UserMenu.tsx +111 -111
- package/src/components/error/ErrorBoundary.tsx +38 -38
- package/src/components/error/ErrorFeedback.tsx +114 -114
- package/src/components/error/ErrorManager.ts +31 -31
- package/src/components/error/SilentErrorBoundary.tsx +54 -54
- package/src/components/menu/MenuContent.tsx +296 -296
- package/src/components/menu/MenuSections.tsx +270 -270
- package/src/components/menu/PageSelector.tsx +154 -154
- package/src/components/menu/constants.ts +2 -2
- package/src/components/menu/types.ts +112 -112
- package/src/components/menu/use-check-text-overflow.tsx +26 -26
- package/src/components/menu/use-keyboard-controls.tsx +70 -70
- package/src/components/types.ts +15 -15
- package/src/dictionary.ts +25 -25
- package/src/elements.ts +24 -24
- package/src/errors.ts +11 -11
- package/src/index.ts +17 -17
- package/src/layout-context.tsx +22 -22
- package/src/layout.css +467 -466
- package/src/svg/AI.tsx +37 -37
- package/src/svg/EDP.tsx +35 -35
- package/src/svg/Forbidden.tsx +22 -22
- package/src/svg/HUB.tsx +35 -35
- package/src/svg/Logo.tsx +35 -35
- package/src/svg/NotFound.tsx +16 -16
- package/src/svg/ServerError.tsx +33 -33
- package/src/svg/Unauthenticated.tsx +16 -16
- package/src/toaster.tsx +76 -76
- package/src/utils.ts +114 -114
- package/tsconfig.json +8 -8
|
@@ -1,270 +1,270 @@
|
|
|
1
|
-
import { Flex, IconBox, Text } from '@citric/core'
|
|
2
|
-
import { ChevronRight, Cog, Collapse, Expand } from '@citric/icons'
|
|
3
|
-
import { Dictionary, interpolate, useTranslate } from '@stack-spot/portal-translate'
|
|
4
|
-
import { useCallback, useMemo, useState } from 'react'
|
|
5
|
-
import { elementIds, getLayoutElements } from '../../elements'
|
|
6
|
-
import { useAnchorTag } from '../../layout-context'
|
|
7
|
-
import { MenuContent } from './MenuContent'
|
|
8
|
-
import { MenuProps, MenuSection } from './types'
|
|
9
|
-
import { useKeyboardControls } from './use-keyboard-controls'
|
|
10
|
-
|
|
11
|
-
const HIDE_OVERLAY_DELAY_MS = 400
|
|
12
|
-
const MENU_OVERLAY_ID = 'menuContentOverlay'
|
|
13
|
-
|
|
14
|
-
let hideOverlayTask: number | undefined
|
|
15
|
-
|
|
16
|
-
// fixme: this should definitely not be handled like this...
|
|
17
|
-
let attachKeyboardListenersForOverlay: () => void
|
|
18
|
-
let detachKeyboardListenersForOverlay: () => void
|
|
19
|
-
|
|
20
|
-
function hideOverlay() {
|
|
21
|
-
if (hideOverlayTask !== undefined) return
|
|
22
|
-
hideOverlayTask = window.setTimeout(hideOverlayImmediately, HIDE_OVERLAY_DELAY_MS)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getAccessibilityButtonOfSectionWithActiveOverlay(): HTMLElement | null | undefined {
|
|
26
|
-
return document.getElementById(elementIds.menuSections)?.querySelector('button[aria-expanded="true"]')
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// eslint-disable-next-line react-refresh/only-export-components
|
|
30
|
-
export function hideOverlayImmediately() {
|
|
31
|
-
detachKeyboardListenersForOverlay?.()
|
|
32
|
-
const overlay = document.getElementById(MENU_OVERLAY_ID)
|
|
33
|
-
overlay?.setAttribute('inert', '')
|
|
34
|
-
overlay?.setAttribute('aria-hidden', '')
|
|
35
|
-
overlay?.classList.remove('visible')
|
|
36
|
-
getAccessibilityButtonOfSectionWithActiveOverlay()?.setAttribute('aria-expanded', 'false')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function cancelHideOverlayTask() {
|
|
40
|
-
if (hideOverlayTask === undefined) return
|
|
41
|
-
clearTimeout(hideOverlayTask)
|
|
42
|
-
hideOverlayTask = undefined
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function showOverlay() {
|
|
46
|
-
cancelHideOverlayTask()
|
|
47
|
-
const overlay = document.getElementById(MENU_OVERLAY_ID)
|
|
48
|
-
overlay?.removeAttribute('inert')
|
|
49
|
-
overlay?.removeAttribute('aria-hidden')
|
|
50
|
-
overlay?.classList.add('visible')
|
|
51
|
-
attachKeyboardListenersForOverlay?.()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function isMenuContentVisible() {
|
|
55
|
-
return !!document.getElementById('layout')?.classList?.contains('menu-content-visible')
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const Section = ({
|
|
59
|
-
icon,
|
|
60
|
-
label,
|
|
61
|
-
href,
|
|
62
|
-
target,
|
|
63
|
-
onClick,
|
|
64
|
-
active,
|
|
65
|
-
content,
|
|
66
|
-
customContent,
|
|
67
|
-
onOpen,
|
|
68
|
-
setCurrentOverlay,
|
|
69
|
-
id,
|
|
70
|
-
hasContent,
|
|
71
|
-
className,
|
|
72
|
-
}: MenuSection & {
|
|
73
|
-
id: number, setCurrentOverlay: (id: number | undefined) => void, hasContent: boolean,
|
|
74
|
-
}) => {
|
|
75
|
-
const Link = useAnchorTag()
|
|
76
|
-
const contentToRender = typeof content === 'function' ? content() : content
|
|
77
|
-
const t = useTranslate(dictionary)
|
|
78
|
-
function shouldShowOverlay() {
|
|
79
|
-
/* The overlay should appear if:
|
|
80
|
-
* 1. The menu is compacted showing only the icons
|
|
81
|
-
* 2. The section has some content to render OR:
|
|
82
|
-
* 3. The section is active and there is a contextual menu for the active page.
|
|
83
|
-
*/
|
|
84
|
-
const { layout } = getLayoutElements()
|
|
85
|
-
const isCompactedOnlyIcons = layout?.classList.contains('menu-compact')
|
|
86
|
-
return isCompactedOnlyIcons && (!!contentToRender || !!customContent || (hasContent && active))
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function prepareShowOverlay(event: React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.KeyboardEvent<any>) {
|
|
90
|
-
if (!shouldShowOverlay()) return
|
|
91
|
-
onOpen?.()
|
|
92
|
-
const anchorElement = event.target as HTMLElement
|
|
93
|
-
const accessibilityButton = anchorElement?.parentElement?.querySelector('button') as HTMLElement
|
|
94
|
-
accessibilityButton?.setAttribute('aria-expanded', 'true')
|
|
95
|
-
setCurrentOverlay(id)
|
|
96
|
-
showOverlay()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function click() {
|
|
100
|
-
if (onClick) onClick()
|
|
101
|
-
hideOverlayImmediately()
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const labelText = typeof label === 'string' ? label : label.id
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<>
|
|
108
|
-
<li
|
|
109
|
-
role="menuitem"
|
|
110
|
-
key={labelText}
|
|
111
|
-
title={labelText}
|
|
112
|
-
className={`section-submenu ${className || ''} ${active ? 'active' : ''}`}
|
|
113
|
-
aria-selected={active}>
|
|
114
|
-
<Link
|
|
115
|
-
href={href}
|
|
116
|
-
target={target}
|
|
117
|
-
onClick={click}
|
|
118
|
-
onMouseEnter={prepareShowOverlay}
|
|
119
|
-
onMouseLeave={() => shouldShowOverlay() && hideOverlay()}
|
|
120
|
-
title={labelText}
|
|
121
|
-
aria-label={labelText}
|
|
122
|
-
onKeyDown={onClick ? e => e.key === 'Enter' && onClick() : undefined}
|
|
123
|
-
{...(active ? { 'aria-current': 'page' } : undefined)}
|
|
124
|
-
{...(!href ? { 'tabIndex': 0 } : undefined)}
|
|
125
|
-
>
|
|
126
|
-
<Flex alignItems="center" justifyContent="center" px={5}>
|
|
127
|
-
<IconBox>{icon}</IconBox>
|
|
128
|
-
{typeof label === 'string' ? <Text appearance="microtext1" className="section-label" ml={3}>{label}</Text> : label.element}
|
|
129
|
-
</Flex>
|
|
130
|
-
</Link>
|
|
131
|
-
{shouldShowOverlay() &&
|
|
132
|
-
<IconBox size="sm" className="section-submenu-icon"
|
|
133
|
-
as="button"
|
|
134
|
-
aria-label={interpolate(t.menuOptions, label)}
|
|
135
|
-
aria-controls={MENU_OVERLAY_ID}
|
|
136
|
-
aria-expanded={false}
|
|
137
|
-
onKeyDown={(event) => {
|
|
138
|
-
if (event.key === 'Enter') {
|
|
139
|
-
prepareShowOverlay(event)
|
|
140
|
-
}
|
|
141
|
-
}}>
|
|
142
|
-
<ChevronRight />
|
|
143
|
-
</IconBox>
|
|
144
|
-
}
|
|
145
|
-
</li>
|
|
146
|
-
</>
|
|
147
|
-
)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const OverlayRenderer = ({ content, customContent }: Pick<MenuSection, 'content' | 'customContent'>) => {
|
|
151
|
-
if (customContent) {
|
|
152
|
-
return <div id="custom-selectable-item"> {customContent} </div>
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const data = typeof content === 'function' ? content() : content
|
|
156
|
-
return <MenuContent {...data} />
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
|
|
160
|
-
const Link = useAnchorTag()
|
|
161
|
-
const t = useTranslate(dictionary)
|
|
162
|
-
// this is a mock state only used to force an update on the component.
|
|
163
|
-
const [_, setUpdate] = useState(0)
|
|
164
|
-
|
|
165
|
-
const toggleMenu = useCallback((hasContent: boolean) => {
|
|
166
|
-
const layout = document.getElementById('layout')
|
|
167
|
-
if (!layout) return
|
|
168
|
-
if (layout.classList.contains('menu-compact')) {
|
|
169
|
-
layout.classList.remove('menu-compact')
|
|
170
|
-
} else {
|
|
171
|
-
layout.classList.add('menu-compact')
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (hasContent) {
|
|
175
|
-
if (layout.classList.contains('menu-content-visible')) {
|
|
176
|
-
layout.classList.remove('menu-content-visible')
|
|
177
|
-
} else {
|
|
178
|
-
layout.classList.add('menu-content-visible')
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
setUpdate(current => current + 1)
|
|
182
|
-
}, [])
|
|
183
|
-
// the current overlay showing, when the user hovers the section icon. This is the index of the item in the sections array.
|
|
184
|
-
const [currentOverlay, setCurrentOverlay] = useState<number | undefined>()
|
|
185
|
-
|
|
186
|
-
const sectionItems = useMemo(
|
|
187
|
-
() => sections.map((s, i) => <Section key={i} id={i} {...s} setCurrentOverlay={setCurrentOverlay}
|
|
188
|
-
hasContent={!!props.content || !!props.customContent} />),
|
|
189
|
-
[sections],
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
function onPressEscape() {
|
|
193
|
-
getAccessibilityButtonOfSectionWithActiveOverlay()?.focus()
|
|
194
|
-
hideOverlayImmediately()
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const { keyboardControlledElement: overlayRef, attachKeyboardListeners, detachKeyboardListeners } = useKeyboardControls({
|
|
198
|
-
onPressEscape,
|
|
199
|
-
querySelectors: 'li a.action, #custom-selectable-item button, #custom-selectable-item input',
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
// fixme: this should definitely not be handled like this...
|
|
203
|
-
attachKeyboardListenersForOverlay = attachKeyboardListeners
|
|
204
|
-
detachKeyboardListenersForOverlay = detachKeyboardListeners
|
|
205
|
-
|
|
206
|
-
/* This function renders the section preview in the overlay in normal circumstances. If the menu is hidden and the section is active,
|
|
207
|
-
instead of rendering the section preview, it will render the actual menu content, which would be invisible otherwise.
|
|
208
|
-
Below, the key is of extreme importance. It ensures React will consider every section content to be an entirely different
|
|
209
|
-
component. Without this, React would see the content changing every time a new section is hovered. Since the content might be a
|
|
210
|
-
hook, this would cause some serious problems. */
|
|
211
|
-
function renderMenuOverlay() {
|
|
212
|
-
if (currentOverlay === undefined) return null
|
|
213
|
-
const shouldRenderMenuContentInstead = !isMenuContentVisible() && sections[currentOverlay].active &&
|
|
214
|
-
(!!props.content || !!props.customContent)
|
|
215
|
-
return shouldRenderMenuContentInstead
|
|
216
|
-
? <OverlayRenderer key={'contentKey' in props ? props.contentKey : undefined} content={props.content}
|
|
217
|
-
customContent={props.customContent} />
|
|
218
|
-
: <OverlayRenderer key={currentOverlay} content={sections[currentOverlay].content}
|
|
219
|
-
customContent={sections[currentOverlay].customContent} />
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
return (
|
|
223
|
-
<>
|
|
224
|
-
<ul>{sectionItems}</ul>
|
|
225
|
-
|
|
226
|
-
<Flex mb={7} alignItems="center">
|
|
227
|
-
<button className="toggle sections-footer" onClick={() => toggleMenu(!!props.content || !!props.customContent)}
|
|
228
|
-
title={t.toggle} tabIndex={-1}>
|
|
229
|
-
<IconBox>
|
|
230
|
-
<Expand className="expand" />
|
|
231
|
-
<Collapse className="collapse" />
|
|
232
|
-
</IconBox>
|
|
233
|
-
<Text appearance="microtext1" ml={8} className="collapse" colorScheme="light.contrastText">{t.hide}</Text>
|
|
234
|
-
</button>
|
|
235
|
-
{(props.settings?.show) &&
|
|
236
|
-
<Link href={props.settings?.href} onClick={props.settings?.onClick}
|
|
237
|
-
className="sections-footer toggle"
|
|
238
|
-
{...(props.settings.active ? { 'aria-current': 'page' } : undefined)}>
|
|
239
|
-
<IconBox aria-label={t.settingsIcon}>
|
|
240
|
-
<Cog />
|
|
241
|
-
</IconBox>
|
|
242
|
-
<Text appearance="microtext1" ml={8} className="collapse">{t.settings}</Text>
|
|
243
|
-
</Link>
|
|
244
|
-
}
|
|
245
|
-
</Flex>
|
|
246
|
-
|
|
247
|
-
<div id={MENU_OVERLAY_ID} onMouseEnter={showOverlay} onMouseLeave={hideOverlay} ref={overlayRef}>
|
|
248
|
-
{renderMenuOverlay()}
|
|
249
|
-
<div className="arrow"></div>
|
|
250
|
-
</div>
|
|
251
|
-
</>
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const dictionary = {
|
|
256
|
-
en: {
|
|
257
|
-
toggle: 'Show or hide the menu',
|
|
258
|
-
menuOptions: 'View $0 menu options',
|
|
259
|
-
settings: 'Settings',
|
|
260
|
-
settingsIcon: 'Settings icon',
|
|
261
|
-
hide: 'Hide',
|
|
262
|
-
},
|
|
263
|
-
pt: {
|
|
264
|
-
toggle: 'Visualizar ou esconder o menu',
|
|
265
|
-
menuOptions: 'Visualizar opções do menu $0',
|
|
266
|
-
settings: 'Configurações',
|
|
267
|
-
settingsIcon: 'Icone de configurações',
|
|
268
|
-
hide: 'Esconder',
|
|
269
|
-
},
|
|
270
|
-
} satisfies Dictionary
|
|
1
|
+
import { Flex, IconBox, Text } from '@citric/core'
|
|
2
|
+
import { ChevronRight, Cog, Collapse, Expand } from '@citric/icons'
|
|
3
|
+
import { Dictionary, interpolate, useTranslate } from '@stack-spot/portal-translate'
|
|
4
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
5
|
+
import { elementIds, getLayoutElements } from '../../elements'
|
|
6
|
+
import { useAnchorTag } from '../../layout-context'
|
|
7
|
+
import { MenuContent } from './MenuContent'
|
|
8
|
+
import { MenuProps, MenuSection } from './types'
|
|
9
|
+
import { useKeyboardControls } from './use-keyboard-controls'
|
|
10
|
+
|
|
11
|
+
const HIDE_OVERLAY_DELAY_MS = 400
|
|
12
|
+
const MENU_OVERLAY_ID = 'menuContentOverlay'
|
|
13
|
+
|
|
14
|
+
let hideOverlayTask: number | undefined
|
|
15
|
+
|
|
16
|
+
// fixme: this should definitely not be handled like this...
|
|
17
|
+
let attachKeyboardListenersForOverlay: () => void
|
|
18
|
+
let detachKeyboardListenersForOverlay: () => void
|
|
19
|
+
|
|
20
|
+
function hideOverlay() {
|
|
21
|
+
if (hideOverlayTask !== undefined) return
|
|
22
|
+
hideOverlayTask = window.setTimeout(hideOverlayImmediately, HIDE_OVERLAY_DELAY_MS)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getAccessibilityButtonOfSectionWithActiveOverlay(): HTMLElement | null | undefined {
|
|
26
|
+
return document.getElementById(elementIds.menuSections)?.querySelector('button[aria-expanded="true"]')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line react-refresh/only-export-components
|
|
30
|
+
export function hideOverlayImmediately() {
|
|
31
|
+
detachKeyboardListenersForOverlay?.()
|
|
32
|
+
const overlay = document.getElementById(MENU_OVERLAY_ID)
|
|
33
|
+
overlay?.setAttribute('inert', '')
|
|
34
|
+
overlay?.setAttribute('aria-hidden', '')
|
|
35
|
+
overlay?.classList.remove('visible')
|
|
36
|
+
getAccessibilityButtonOfSectionWithActiveOverlay()?.setAttribute('aria-expanded', 'false')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cancelHideOverlayTask() {
|
|
40
|
+
if (hideOverlayTask === undefined) return
|
|
41
|
+
clearTimeout(hideOverlayTask)
|
|
42
|
+
hideOverlayTask = undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function showOverlay() {
|
|
46
|
+
cancelHideOverlayTask()
|
|
47
|
+
const overlay = document.getElementById(MENU_OVERLAY_ID)
|
|
48
|
+
overlay?.removeAttribute('inert')
|
|
49
|
+
overlay?.removeAttribute('aria-hidden')
|
|
50
|
+
overlay?.classList.add('visible')
|
|
51
|
+
attachKeyboardListenersForOverlay?.()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isMenuContentVisible() {
|
|
55
|
+
return !!document.getElementById('layout')?.classList?.contains('menu-content-visible')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const Section = ({
|
|
59
|
+
icon,
|
|
60
|
+
label,
|
|
61
|
+
href,
|
|
62
|
+
target,
|
|
63
|
+
onClick,
|
|
64
|
+
active,
|
|
65
|
+
content,
|
|
66
|
+
customContent,
|
|
67
|
+
onOpen,
|
|
68
|
+
setCurrentOverlay,
|
|
69
|
+
id,
|
|
70
|
+
hasContent,
|
|
71
|
+
className,
|
|
72
|
+
}: MenuSection & {
|
|
73
|
+
id: number, setCurrentOverlay: (id: number | undefined) => void, hasContent: boolean,
|
|
74
|
+
}) => {
|
|
75
|
+
const Link = useAnchorTag()
|
|
76
|
+
const contentToRender = typeof content === 'function' ? content() : content
|
|
77
|
+
const t = useTranslate(dictionary)
|
|
78
|
+
function shouldShowOverlay() {
|
|
79
|
+
/* The overlay should appear if:
|
|
80
|
+
* 1. The menu is compacted showing only the icons
|
|
81
|
+
* 2. The section has some content to render OR:
|
|
82
|
+
* 3. The section is active and there is a contextual menu for the active page.
|
|
83
|
+
*/
|
|
84
|
+
const { layout } = getLayoutElements()
|
|
85
|
+
const isCompactedOnlyIcons = layout?.classList.contains('menu-compact')
|
|
86
|
+
return isCompactedOnlyIcons && (!!contentToRender || !!customContent || (hasContent && active))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function prepareShowOverlay(event: React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.KeyboardEvent<any>) {
|
|
90
|
+
if (!shouldShowOverlay()) return
|
|
91
|
+
onOpen?.()
|
|
92
|
+
const anchorElement = event.target as HTMLElement
|
|
93
|
+
const accessibilityButton = anchorElement?.parentElement?.querySelector('button') as HTMLElement
|
|
94
|
+
accessibilityButton?.setAttribute('aria-expanded', 'true')
|
|
95
|
+
setCurrentOverlay(id)
|
|
96
|
+
showOverlay()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function click() {
|
|
100
|
+
if (onClick) onClick()
|
|
101
|
+
hideOverlayImmediately()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const labelText = typeof label === 'string' ? label : label.id
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
<li
|
|
109
|
+
role="menuitem"
|
|
110
|
+
key={labelText}
|
|
111
|
+
title={labelText}
|
|
112
|
+
className={`section-submenu ${className || ''} ${active ? 'active' : ''}`}
|
|
113
|
+
aria-selected={active}>
|
|
114
|
+
<Link
|
|
115
|
+
href={href}
|
|
116
|
+
target={target}
|
|
117
|
+
onClick={click}
|
|
118
|
+
onMouseEnter={prepareShowOverlay}
|
|
119
|
+
onMouseLeave={() => shouldShowOverlay() && hideOverlay()}
|
|
120
|
+
title={labelText}
|
|
121
|
+
aria-label={labelText}
|
|
122
|
+
onKeyDown={onClick ? e => e.key === 'Enter' && onClick() : undefined}
|
|
123
|
+
{...(active ? { 'aria-current': 'page' } : undefined)}
|
|
124
|
+
{...(!href ? { 'tabIndex': 0 } : undefined)}
|
|
125
|
+
>
|
|
126
|
+
<Flex alignItems="center" justifyContent="center" px={5}>
|
|
127
|
+
<IconBox>{icon}</IconBox>
|
|
128
|
+
{typeof label === 'string' ? <Text appearance="microtext1" className="section-label" ml={3}>{label}</Text> : label.element}
|
|
129
|
+
</Flex>
|
|
130
|
+
</Link>
|
|
131
|
+
{shouldShowOverlay() &&
|
|
132
|
+
<IconBox size="sm" className="section-submenu-icon"
|
|
133
|
+
as="button"
|
|
134
|
+
aria-label={interpolate(t.menuOptions, label)}
|
|
135
|
+
aria-controls={MENU_OVERLAY_ID}
|
|
136
|
+
aria-expanded={false}
|
|
137
|
+
onKeyDown={(event) => {
|
|
138
|
+
if (event.key === 'Enter') {
|
|
139
|
+
prepareShowOverlay(event)
|
|
140
|
+
}
|
|
141
|
+
}}>
|
|
142
|
+
<ChevronRight />
|
|
143
|
+
</IconBox>
|
|
144
|
+
}
|
|
145
|
+
</li>
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const OverlayRenderer = ({ content, customContent }: Pick<MenuSection, 'content' | 'customContent'>) => {
|
|
151
|
+
if (customContent) {
|
|
152
|
+
return <div id="custom-selectable-item"> {customContent} </div>
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const data = typeof content === 'function' ? content() : content
|
|
156
|
+
return <MenuContent {...data} />
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
|
|
160
|
+
const Link = useAnchorTag()
|
|
161
|
+
const t = useTranslate(dictionary)
|
|
162
|
+
// this is a mock state only used to force an update on the component.
|
|
163
|
+
const [_, setUpdate] = useState(0)
|
|
164
|
+
|
|
165
|
+
const toggleMenu = useCallback((hasContent: boolean) => {
|
|
166
|
+
const layout = document.getElementById('layout')
|
|
167
|
+
if (!layout) return
|
|
168
|
+
if (layout.classList.contains('menu-compact')) {
|
|
169
|
+
layout.classList.remove('menu-compact')
|
|
170
|
+
} else {
|
|
171
|
+
layout.classList.add('menu-compact')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (hasContent) {
|
|
175
|
+
if (layout.classList.contains('menu-content-visible')) {
|
|
176
|
+
layout.classList.remove('menu-content-visible')
|
|
177
|
+
} else {
|
|
178
|
+
layout.classList.add('menu-content-visible')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
setUpdate(current => current + 1)
|
|
182
|
+
}, [])
|
|
183
|
+
// the current overlay showing, when the user hovers the section icon. This is the index of the item in the sections array.
|
|
184
|
+
const [currentOverlay, setCurrentOverlay] = useState<number | undefined>()
|
|
185
|
+
|
|
186
|
+
const sectionItems = useMemo(
|
|
187
|
+
() => sections.map((s, i) => <Section key={i} id={i} {...s} setCurrentOverlay={setCurrentOverlay}
|
|
188
|
+
hasContent={!!props.content || !!props.customContent} />),
|
|
189
|
+
[sections],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
function onPressEscape() {
|
|
193
|
+
getAccessibilityButtonOfSectionWithActiveOverlay()?.focus()
|
|
194
|
+
hideOverlayImmediately()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const { keyboardControlledElement: overlayRef, attachKeyboardListeners, detachKeyboardListeners } = useKeyboardControls({
|
|
198
|
+
onPressEscape,
|
|
199
|
+
querySelectors: 'li a.action, #custom-selectable-item button, #custom-selectable-item input',
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// fixme: this should definitely not be handled like this...
|
|
203
|
+
attachKeyboardListenersForOverlay = attachKeyboardListeners
|
|
204
|
+
detachKeyboardListenersForOverlay = detachKeyboardListeners
|
|
205
|
+
|
|
206
|
+
/* This function renders the section preview in the overlay in normal circumstances. If the menu is hidden and the section is active,
|
|
207
|
+
instead of rendering the section preview, it will render the actual menu content, which would be invisible otherwise.
|
|
208
|
+
Below, the key is of extreme importance. It ensures React will consider every section content to be an entirely different
|
|
209
|
+
component. Without this, React would see the content changing every time a new section is hovered. Since the content might be a
|
|
210
|
+
hook, this would cause some serious problems. */
|
|
211
|
+
function renderMenuOverlay() {
|
|
212
|
+
if (currentOverlay === undefined) return null
|
|
213
|
+
const shouldRenderMenuContentInstead = !isMenuContentVisible() && sections[currentOverlay].active &&
|
|
214
|
+
(!!props.content || !!props.customContent)
|
|
215
|
+
return shouldRenderMenuContentInstead
|
|
216
|
+
? <OverlayRenderer key={'contentKey' in props ? props.contentKey : undefined} content={props.content}
|
|
217
|
+
customContent={props.customContent} />
|
|
218
|
+
: <OverlayRenderer key={currentOverlay} content={sections[currentOverlay].content}
|
|
219
|
+
customContent={sections[currentOverlay].customContent} />
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<>
|
|
224
|
+
<ul>{sectionItems}</ul>
|
|
225
|
+
|
|
226
|
+
<Flex mb={7} alignItems="center">
|
|
227
|
+
<button className="toggle sections-footer" onClick={() => toggleMenu(!!props.content || !!props.customContent)}
|
|
228
|
+
title={t.toggle} tabIndex={-1}>
|
|
229
|
+
<IconBox>
|
|
230
|
+
<Expand className="expand" />
|
|
231
|
+
<Collapse className="collapse" />
|
|
232
|
+
</IconBox>
|
|
233
|
+
<Text appearance="microtext1" ml={8} className="collapse" colorScheme="light.contrastText">{t.hide}</Text>
|
|
234
|
+
</button>
|
|
235
|
+
{(props.settings?.show) &&
|
|
236
|
+
<Link href={props.settings?.href} onClick={props.settings?.onClick}
|
|
237
|
+
className="sections-footer toggle"
|
|
238
|
+
{...(props.settings.active ? { 'aria-current': 'page' } : undefined)}>
|
|
239
|
+
<IconBox aria-label={t.settingsIcon}>
|
|
240
|
+
<Cog />
|
|
241
|
+
</IconBox>
|
|
242
|
+
<Text appearance="microtext1" ml={8} className="collapse">{t.settings}</Text>
|
|
243
|
+
</Link>
|
|
244
|
+
}
|
|
245
|
+
</Flex>
|
|
246
|
+
|
|
247
|
+
<div id={MENU_OVERLAY_ID} onMouseEnter={showOverlay} onMouseLeave={hideOverlay} ref={overlayRef}>
|
|
248
|
+
{renderMenuOverlay()}
|
|
249
|
+
<div className="arrow"></div>
|
|
250
|
+
</div>
|
|
251
|
+
</>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const dictionary = {
|
|
256
|
+
en: {
|
|
257
|
+
toggle: 'Show or hide the menu',
|
|
258
|
+
menuOptions: 'View $0 menu options',
|
|
259
|
+
settings: 'Settings',
|
|
260
|
+
settingsIcon: 'Settings icon',
|
|
261
|
+
hide: 'Hide',
|
|
262
|
+
},
|
|
263
|
+
pt: {
|
|
264
|
+
toggle: 'Visualizar ou esconder o menu',
|
|
265
|
+
menuOptions: 'Visualizar opções do menu $0',
|
|
266
|
+
settings: 'Configurações',
|
|
267
|
+
settingsIcon: 'Icone de configurações',
|
|
268
|
+
hide: 'Esconder',
|
|
269
|
+
},
|
|
270
|
+
} satisfies Dictionary
|