@stack-spot/portal-layout 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Layout.d.ts +2 -2
  3. package/dist/Layout.js +1 -1
  4. package/dist/LayoutOverlayManager.js +6 -6
  5. package/dist/LayoutOverlayManager.js.map +1 -1
  6. package/dist/components/Dialog.d.ts +1 -1
  7. package/dist/components/Dialog.js +1 -1
  8. package/dist/components/Header.d.ts +1 -1
  9. package/dist/components/Header.js +1 -1
  10. package/dist/components/OverlayContent.d.ts +1 -1
  11. package/dist/components/OverlayContent.js +20 -20
  12. package/dist/components/PortalSwitcher.d.ts +1 -1
  13. package/dist/components/PortalSwitcher.js +54 -54
  14. package/dist/components/Toaster.d.ts +2 -2
  15. package/dist/components/Toaster.js +1 -1
  16. package/dist/components/UserMenu.d.ts +1 -1
  17. package/dist/components/UserMenu.d.ts.map +1 -1
  18. package/dist/components/UserMenu.js +44 -42
  19. package/dist/components/UserMenu.js.map +1 -1
  20. package/dist/components/error/ErrorBoundary.d.ts +1 -1
  21. package/dist/components/error/ErrorBoundary.js +1 -1
  22. package/dist/components/error/SilentErrorBoundary.d.ts +1 -1
  23. package/dist/components/error/SilentErrorBoundary.js +1 -1
  24. package/dist/components/menu/MenuContent.d.ts +2 -2
  25. package/dist/components/menu/MenuContent.js +123 -123
  26. package/dist/components/menu/MenuContent.js.map +1 -1
  27. package/dist/components/menu/MenuSections.d.ts +1 -1
  28. package/dist/components/menu/MenuSections.js +1 -1
  29. package/dist/components/menu/MenuSections.js.map +1 -1
  30. package/dist/components/menu/PageSelector.d.ts +1 -1
  31. package/dist/components/menu/PageSelector.js +69 -69
  32. package/dist/components/menu/PageSelector.js.map +1 -1
  33. package/dist/components/tour/PortalSwitcherStep.js +1 -1
  34. package/dist/components/user-menu-manager.d.ts +13 -0
  35. package/dist/components/user-menu-manager.d.ts.map +1 -0
  36. package/dist/components/user-menu-manager.js +36 -0
  37. package/dist/components/user-menu-manager.js.map +1 -0
  38. package/dist/layout.css +477 -477
  39. package/dist/toaster.js +1 -1
  40. package/package.json +9 -6
  41. package/readme.md +146 -146
  42. package/src/Layout.tsx +171 -171
  43. package/src/LayoutOverlayManager.tsx +464 -464
  44. package/src/components/Dialog.tsx +140 -140
  45. package/src/components/Header.tsx +62 -62
  46. package/src/components/OverlayContent.tsx +80 -80
  47. package/src/components/PortalSwitcher.tsx +161 -161
  48. package/src/components/Toaster.tsx +95 -95
  49. package/src/components/UserMenu.tsx +127 -124
  50. package/src/components/error/ErrorBoundary.tsx +47 -47
  51. package/src/components/error/ErrorManager.ts +47 -47
  52. package/src/components/error/SilentErrorBoundary.tsx +64 -64
  53. package/src/components/menu/MenuContent.tsx +270 -270
  54. package/src/components/menu/MenuSections.tsx +320 -320
  55. package/src/components/menu/PageSelector.tsx +164 -164
  56. package/src/components/menu/constants.ts +2 -2
  57. package/src/components/menu/types.ts +205 -205
  58. package/src/components/tour/PortalSwitcherStep.tsx +39 -39
  59. package/src/components/types.ts +1 -1
  60. package/src/components/user-menu-manager.ts +31 -0
  61. package/src/dictionary.ts +28 -28
  62. package/src/elements.ts +30 -30
  63. package/src/errors.ts +11 -11
  64. package/src/index.ts +14 -14
  65. package/src/layout.css +477 -477
  66. package/src/toaster.tsx +153 -153
  67. package/src/utils.ts +29 -29
  68. package/tsconfig.json +8 -8
@@ -1,320 +1,320 @@
1
- import { Flex, IconBox, Text } from '@citric/core'
2
- import { ChevronRight, Cog, Collapse, Expand } from '@citric/icons'
3
- import { useKeyboardControls } from '@stack-spot/portal-components'
4
- import { useAnchorTag } from '@stack-spot/portal-components/anchor'
5
- import { Dictionary, interpolate, useTranslate } from '@stack-spot/portal-translate'
6
- import { useCallback, useMemo, useState } from 'react'
7
- import { elementIds, getLayoutElements } from '../../elements'
8
- import { MenuContent } from './MenuContent'
9
- import { MenuProps, MenuSection } from './types'
10
-
11
- /**
12
- * Amount of time to wait before hiding the menu overlay once the mouse leaves its area.
13
- */
14
- const HIDE_OVERLAY_DELAY_MS = 400
15
- const MENU_OVERLAY_ID = 'menuContentOverlay'
16
-
17
- /**
18
- * Pointer to the latest "hideOverlay" task. This allows the operation to be cancelled.
19
- */
20
- let hideOverlayTask: number | undefined
21
-
22
- /**
23
- * Accessibility. Makes the menu overlay accessible through the keyboard.
24
- */
25
- let attachKeyboardListenersForOverlay: () => void
26
- /**
27
- * Accessibility. Makes the menu overlay invisible to the keyboard.
28
- */
29
- let detachKeyboardListenersForOverlay: () => void
30
-
31
- /**
32
- * Hides the menu overlay after HIDE_OVERLAY_DELAY_MS. This operation may be canceled.
33
- *
34
- * This gives the user some time to move the mouse outside the overlay for while before it disappears. If the user moves the mouse out of
35
- * the overlay and, before HIDE_OVERLAY_DELAY_MS, moves the mouse back, we want the overlay to keep showing.
36
- */
37
- function hideOverlay() {
38
- if (hideOverlayTask !== undefined) return
39
- hideOverlayTask = window.setTimeout(hideOverlayImmediately, HIDE_OVERLAY_DELAY_MS)
40
- }
41
-
42
- /**
43
- * Accessibility. Returns the accessibility button of the section with the preview (overlay) currently active.
44
- */
45
- function getAccessibilityButtonOfSectionWithActiveOverlay(): HTMLElement | null | undefined {
46
- return document.getElementById(elementIds.menuSections)?.querySelector('button[aria-expanded="true"]')
47
- }
48
-
49
- /**
50
- * Hides the menu overlay without waiting for anything. This is not cancellable.
51
- */
52
- export function hideOverlayImmediately() {
53
- detachKeyboardListenersForOverlay?.()
54
- const overlay = document.getElementById(MENU_OVERLAY_ID)
55
- overlay?.setAttribute('inert', '')
56
- overlay?.setAttribute('aria-hidden', '')
57
- overlay?.classList.remove('visible')
58
- getAccessibilityButtonOfSectionWithActiveOverlay()?.setAttribute('aria-expanded', 'false')
59
- }
60
-
61
- /**
62
- * If `hideOverlay` was called and not fulfilled yet, this cancels the task, preventing the overlay from closing.
63
- */
64
- function cancelHideOverlayTask() {
65
- if (hideOverlayTask === undefined) return
66
- clearTimeout(hideOverlayTask)
67
- hideOverlayTask = undefined
68
- }
69
-
70
- /**
71
- * Shows the menu overlay.
72
- */
73
- function showOverlay() {
74
- cancelHideOverlayTask()
75
- const overlay = document.getElementById(MENU_OVERLAY_ID)
76
- overlay?.removeAttribute('inert')
77
- overlay?.removeAttribute('aria-hidden')
78
- overlay?.classList.add('visible')
79
- attachKeyboardListenersForOverlay?.()
80
- }
81
-
82
- /**
83
- * Checks if the menu content (2nd menu list from left to right) is visible or not.
84
- * The menu content is visible if:
85
- * 1. The menu is not compact (the button at the end of the section list compacts/expands the menu);
86
- * 2. If the current section has any menu content.
87
- */
88
- function isMenuContentVisible() {
89
- return !!document.getElementById('layout')?.classList?.contains('menu-content-visible')
90
- }
91
-
92
- /**
93
- * A section in the the menu-sections.
94
- *
95
- * A section in the menu is responsible for rendering the section icon, label, accessibility button and controlling the menu overlay.
96
- * @param props React props for the component {@link MenuSection} & { id, setCurrentOverlay }. Id identifies the current section and
97
- * setCurrentOverlay controls the overlay (preview) content.
98
- */
99
- const Section = ({
100
- icon,
101
- label,
102
- href,
103
- target,
104
- onClick,
105
- active,
106
- content,
107
- customContent,
108
- setCurrentOverlay,
109
- id,
110
- hasContent,
111
- className,
112
- }: MenuSection & {
113
- id: number, setCurrentOverlay: (id: number | undefined) => void, hasContent: boolean,
114
- }) => {
115
- const Link = useAnchorTag()
116
- const contentToRender = typeof content === 'function' ? content() : content
117
- const t = useTranslate(dictionary)
118
- function shouldShowOverlay() {
119
- /* The overlay should appear if:
120
- * 1. The menu is compacted showing only the icons
121
- * 2. The section has some content to render OR:
122
- * 3. The section is active and there is a contextual menu for the active page.
123
- */
124
- const { layout } = getLayoutElements()
125
- const isCompactedOnlyIcons = layout?.classList.contains('menu-compact')
126
- return isCompactedOnlyIcons && (!!contentToRender || !!customContent || (hasContent && active))
127
- }
128
-
129
- function prepareShowOverlay(event: React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.KeyboardEvent<any>) {
130
- if (!shouldShowOverlay()) return
131
- const anchorElement = event.target as HTMLElement
132
- const accessibilityButton = anchorElement?.parentElement?.querySelector('button') as HTMLElement
133
- accessibilityButton?.setAttribute('aria-expanded', 'true')
134
- setCurrentOverlay(id)
135
- showOverlay()
136
- }
137
-
138
- function click() {
139
- if (onClick) onClick()
140
- hideOverlayImmediately()
141
- }
142
-
143
- const labelText = typeof label === 'string' ? label : label.id
144
-
145
- return (
146
- <li
147
- role="menuitem"
148
- key={labelText}
149
- title={labelText}
150
- className={`section-submenu ${className || ''} ${active ? 'active' : ''}`}
151
- aria-selected={active}>
152
- <Link
153
- href={href}
154
- target={target}
155
- onClick={click}
156
- onMouseEnter={prepareShowOverlay}
157
- onMouseLeave={() => shouldShowOverlay() && hideOverlay()}
158
- title={labelText}
159
- aria-label={labelText}
160
- onKeyDown={onClick ? e => e.key === 'Enter' && onClick() : undefined}
161
- {...(active ? { 'aria-current': 'page' } : undefined)}
162
- {...(!href ? { 'tabIndex': 0 } : undefined)}
163
- >
164
- <Flex alignItems="center" justifyContent="center" px={5}>
165
- <IconBox>{icon}</IconBox>
166
- {typeof label === 'string' ? <Text appearance="microtext1" className="section-label" ml={3}>{label}</Text> : label.element}
167
- </Flex>
168
- </Link>
169
- {shouldShowOverlay() &&
170
- <IconBox size="sm" className="section-submenu-icon"
171
- as="button"
172
- aria-label={interpolate(t.menuOptions, label)}
173
- aria-controls={MENU_OVERLAY_ID}
174
- aria-expanded={false}
175
- onKeyDown={(event) => {
176
- if (event.key === 'Enter') {
177
- prepareShowOverlay(event)
178
- }
179
- }}>
180
- <ChevronRight />
181
- </IconBox>
182
- }
183
- </li>
184
- )
185
- }
186
-
187
- /**
188
- * Renders the overlay content.
189
- * @param props the content of the overlay, can be either customized (react component), a config object or a function that creates the
190
- * config object.
191
- * @returns the content
192
- */
193
- const OverlayRenderer = ({ content, customContent }: Pick<MenuSection, 'content' | 'customContent'>) => {
194
- if (customContent) {
195
- return <div id="custom-selectable-item">{customContent}</div>
196
- }
197
-
198
- const data = typeof content === 'function' ? content() : content
199
- return <MenuContent {...data} />
200
- }
201
-
202
- /**
203
- * Renders a menu-sections interface.
204
- *
205
- * Considering the Stackspot UI, this is the "menu sections", not the "menu content", i.e. it's the first menu from left to right, the
206
- * one with the icons and section names: the main menu.
207
- * @param props the props for the component {@link MenuProps}.
208
- */
209
- export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
210
- const Link = useAnchorTag()
211
- const t = useTranslate(dictionary)
212
- // this is a mock state only used to force an update on the component.
213
- const [_, setUpdate] = useState(0)
214
-
215
- const toggleMenu = useCallback((hasContent: boolean) => {
216
- const layout = document.getElementById('layout')
217
- if (!layout) return
218
- if (layout.classList.contains('menu-compact')) {
219
- layout.classList.remove('menu-compact')
220
- } else {
221
- layout.classList.add('menu-compact')
222
- }
223
-
224
- if (hasContent) {
225
- if (layout.classList.contains('menu-content-visible')) {
226
- layout.classList.remove('menu-content-visible')
227
- } else {
228
- layout.classList.add('menu-content-visible')
229
- }
230
- }
231
- setUpdate(current => current + 1)
232
- }, [])
233
- // the current overlay showing, when the user hovers the section icon. This is the index of the item in the sections array.
234
- const [currentOverlay, setCurrentOverlay] = useState<number | undefined>()
235
-
236
- const sectionItems = useMemo(
237
- () => sections.filter(s => !s.hidden).map((s, i) => <Section key={i} id={i} {...s} setCurrentOverlay={setCurrentOverlay}
238
- hasContent={!!props.content || !!props.customContent} />),
239
- [sections],
240
- )
241
-
242
- function onPressEscape() {
243
- getAccessibilityButtonOfSectionWithActiveOverlay()?.focus()
244
- hideOverlayImmediately()
245
- }
246
-
247
- const { keyboardControlledElement: overlayRef, attachKeyboardListeners, detachKeyboardListeners } = useKeyboardControls({
248
- onPressEscape,
249
- querySelectors: 'li a.action, #custom-selectable-item button, #custom-selectable-item input',
250
- })
251
-
252
- // this only works because we have a single section menu in the site. This workaround was created since the keyboard controls were
253
- // transformed into a hook. This is not ideal and it would be a good idea to rewrite this code without the need for this.
254
- attachKeyboardListenersForOverlay = attachKeyboardListeners
255
- detachKeyboardListenersForOverlay = detachKeyboardListeners
256
-
257
- /* This function renders the section preview in the overlay in normal circumstances. If the menu is hidden and the section is active,
258
- instead of rendering the section preview, it will render the actual menu content, which would be invisible otherwise.
259
- Below, the key is of extreme importance. It ensures React will consider every section content to be an entirely different
260
- component. Without this, React would see the content changing every time a new section is hovered. Since the content might be a
261
- hook, this would cause some serious problems. */
262
- function renderMenuOverlay() {
263
- if (currentOverlay === undefined) return null
264
- const shouldRenderMenuContentInstead = !isMenuContentVisible() && sections[currentOverlay].active &&
265
- (!!props.content || !!props.customContent)
266
- return shouldRenderMenuContentInstead
267
- ? <OverlayRenderer key={'contentKey' in props ? props.contentKey : undefined} content={props.content}
268
- customContent={props.customContent} />
269
- : <OverlayRenderer key={currentOverlay} content={sections[currentOverlay].content}
270
- customContent={sections[currentOverlay].customContent} />
271
- }
272
-
273
- return (
274
- <>
275
- <ul>{sectionItems}</ul>
276
-
277
- <Flex mb={7} alignItems="center">
278
- <button className="toggle sections-footer" onClick={() => toggleMenu(!!props.content || !!props.customContent)}
279
- title={t.toggle} tabIndex={-1}>
280
- <IconBox>
281
- <Expand className="expand" />
282
- <Collapse className="collapse" />
283
- </IconBox>
284
- <Text appearance="microtext1" ml={8} className="collapse" colorScheme="light.contrastText">{t.hide}</Text>
285
- </button>
286
- {(props.settings?.show) &&
287
- <Link href={props.settings?.href} onClick={props.settings?.onClick}
288
- className="sections-footer toggle"
289
- {...(props.settings.active ? { 'aria-current': 'page' } : undefined)}>
290
- <IconBox aria-label={t.settingsIcon}>
291
- <Cog />
292
- </IconBox>
293
- <Text appearance="microtext1" ml={8} className="collapse">{t.settings}</Text>
294
- </Link>
295
- }
296
- </Flex>
297
-
298
- <div id={MENU_OVERLAY_ID} onMouseEnter={showOverlay} onMouseLeave={hideOverlay} ref={overlayRef}>
299
- {renderMenuOverlay()}
300
- </div>
301
- </>
302
- )
303
- }
304
-
305
- const dictionary = {
306
- en: {
307
- toggle: 'Show or hide the menu',
308
- menuOptions: 'View $0 menu options',
309
- settings: 'Settings',
310
- settingsIcon: 'Settings icon',
311
- hide: 'Hide',
312
- },
313
- pt: {
314
- toggle: 'Visualizar ou esconder o menu',
315
- menuOptions: 'Visualizar opções do menu $0',
316
- settings: 'Configurações',
317
- settingsIcon: 'Ícone de configurações',
318
- hide: 'Esconder',
319
- },
320
- } satisfies Dictionary
1
+ import { Flex, IconBox, Text } from '@citric/core'
2
+ import { ChevronRight, Cog, Collapse, Expand } from '@citric/icons'
3
+ import { useKeyboardControls } from '@stack-spot/portal-components'
4
+ import { useAnchorTag } from '@stack-spot/portal-components/anchor'
5
+ import { Dictionary, interpolate, useTranslate } from '@stack-spot/portal-translate'
6
+ import { useCallback, useMemo, useState } from 'react'
7
+ import { elementIds, getLayoutElements } from '../../elements'
8
+ import { MenuContent } from './MenuContent'
9
+ import { MenuProps, MenuSection } from './types'
10
+
11
+ /**
12
+ * Amount of time to wait before hiding the menu overlay once the mouse leaves its area.
13
+ */
14
+ const HIDE_OVERLAY_DELAY_MS = 400
15
+ const MENU_OVERLAY_ID = 'menuContentOverlay'
16
+
17
+ /**
18
+ * Pointer to the latest "hideOverlay" task. This allows the operation to be cancelled.
19
+ */
20
+ let hideOverlayTask: number | undefined
21
+
22
+ /**
23
+ * Accessibility. Makes the menu overlay accessible through the keyboard.
24
+ */
25
+ let attachKeyboardListenersForOverlay: () => void
26
+ /**
27
+ * Accessibility. Makes the menu overlay invisible to the keyboard.
28
+ */
29
+ let detachKeyboardListenersForOverlay: () => void
30
+
31
+ /**
32
+ * Hides the menu overlay after HIDE_OVERLAY_DELAY_MS. This operation may be canceled.
33
+ *
34
+ * This gives the user some time to move the mouse outside the overlay for while before it disappears. If the user moves the mouse out of
35
+ * the overlay and, before HIDE_OVERLAY_DELAY_MS, moves the mouse back, we want the overlay to keep showing.
36
+ */
37
+ function hideOverlay() {
38
+ if (hideOverlayTask !== undefined) return
39
+ hideOverlayTask = window.setTimeout(hideOverlayImmediately, HIDE_OVERLAY_DELAY_MS)
40
+ }
41
+
42
+ /**
43
+ * Accessibility. Returns the accessibility button of the section with the preview (overlay) currently active.
44
+ */
45
+ function getAccessibilityButtonOfSectionWithActiveOverlay(): HTMLElement | null | undefined {
46
+ return document.getElementById(elementIds.menuSections)?.querySelector('button[aria-expanded="true"]')
47
+ }
48
+
49
+ /**
50
+ * Hides the menu overlay without waiting for anything. This is not cancellable.
51
+ */
52
+ export function hideOverlayImmediately() {
53
+ detachKeyboardListenersForOverlay?.()
54
+ const overlay = document.getElementById(MENU_OVERLAY_ID)
55
+ overlay?.setAttribute('inert', '')
56
+ overlay?.setAttribute('aria-hidden', '')
57
+ overlay?.classList.remove('visible')
58
+ getAccessibilityButtonOfSectionWithActiveOverlay()?.setAttribute('aria-expanded', 'false')
59
+ }
60
+
61
+ /**
62
+ * If `hideOverlay` was called and not fulfilled yet, this cancels the task, preventing the overlay from closing.
63
+ */
64
+ function cancelHideOverlayTask() {
65
+ if (hideOverlayTask === undefined) return
66
+ clearTimeout(hideOverlayTask)
67
+ hideOverlayTask = undefined
68
+ }
69
+
70
+ /**
71
+ * Shows the menu overlay.
72
+ */
73
+ function showOverlay() {
74
+ cancelHideOverlayTask()
75
+ const overlay = document.getElementById(MENU_OVERLAY_ID)
76
+ overlay?.removeAttribute('inert')
77
+ overlay?.removeAttribute('aria-hidden')
78
+ overlay?.classList.add('visible')
79
+ attachKeyboardListenersForOverlay?.()
80
+ }
81
+
82
+ /**
83
+ * Checks if the menu content (2nd menu list from left to right) is visible or not.
84
+ * The menu content is visible if:
85
+ * 1. The menu is not compact (the button at the end of the section list compacts/expands the menu);
86
+ * 2. If the current section has any menu content.
87
+ */
88
+ function isMenuContentVisible() {
89
+ return !!document.getElementById('layout')?.classList?.contains('menu-content-visible')
90
+ }
91
+
92
+ /**
93
+ * A section in the the menu-sections.
94
+ *
95
+ * A section in the menu is responsible for rendering the section icon, label, accessibility button and controlling the menu overlay.
96
+ * @param props React props for the component {@link MenuSection} & { id, setCurrentOverlay }. Id identifies the current section and
97
+ * setCurrentOverlay controls the overlay (preview) content.
98
+ */
99
+ const Section = ({
100
+ icon,
101
+ label,
102
+ href,
103
+ target,
104
+ onClick,
105
+ active,
106
+ content,
107
+ customContent,
108
+ setCurrentOverlay,
109
+ id,
110
+ hasContent,
111
+ className,
112
+ }: MenuSection & {
113
+ id: number, setCurrentOverlay: (id: number | undefined) => void, hasContent: boolean,
114
+ }) => {
115
+ const Link = useAnchorTag()
116
+ const contentToRender = typeof content === 'function' ? content() : content
117
+ const t = useTranslate(dictionary)
118
+ function shouldShowOverlay() {
119
+ /* The overlay should appear if:
120
+ * 1. The menu is compacted showing only the icons
121
+ * 2. The section has some content to render OR:
122
+ * 3. The section is active and there is a contextual menu for the active page.
123
+ */
124
+ const { layout } = getLayoutElements()
125
+ const isCompactedOnlyIcons = layout?.classList.contains('menu-compact')
126
+ return isCompactedOnlyIcons && (!!contentToRender || !!customContent || (hasContent && active))
127
+ }
128
+
129
+ function prepareShowOverlay(event: React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.KeyboardEvent<any>) {
130
+ if (!shouldShowOverlay()) return
131
+ const anchorElement = event.target as HTMLElement
132
+ const accessibilityButton = anchorElement?.parentElement?.querySelector('button') as HTMLElement
133
+ accessibilityButton?.setAttribute('aria-expanded', 'true')
134
+ setCurrentOverlay(id)
135
+ showOverlay()
136
+ }
137
+
138
+ function click() {
139
+ if (onClick) onClick()
140
+ hideOverlayImmediately()
141
+ }
142
+
143
+ const labelText = typeof label === 'string' ? label : label.id
144
+
145
+ return (
146
+ <li
147
+ role="menuitem"
148
+ key={labelText}
149
+ title={labelText}
150
+ className={`section-submenu ${className || ''} ${active ? 'active' : ''}`}
151
+ aria-selected={active}>
152
+ <Link
153
+ href={href}
154
+ target={target}
155
+ onClick={click}
156
+ onMouseEnter={prepareShowOverlay}
157
+ onMouseLeave={() => shouldShowOverlay() && hideOverlay()}
158
+ title={labelText}
159
+ aria-label={labelText}
160
+ onKeyDown={onClick ? e => e.key === 'Enter' && onClick() : undefined}
161
+ {...(active ? { 'aria-current': 'page' } : undefined)}
162
+ {...(!href ? { 'tabIndex': 0 } : undefined)}
163
+ >
164
+ <Flex alignItems="center" justifyContent="center" px={5}>
165
+ <IconBox>{icon}</IconBox>
166
+ {typeof label === 'string' ? <Text appearance="microtext1" className="section-label" ml={3}>{label}</Text> : label.element}
167
+ </Flex>
168
+ </Link>
169
+ {shouldShowOverlay() &&
170
+ <IconBox size="sm" className="section-submenu-icon"
171
+ as="button"
172
+ aria-label={interpolate(t.menuOptions, label)}
173
+ aria-controls={MENU_OVERLAY_ID}
174
+ aria-expanded={false}
175
+ onKeyDown={(event) => {
176
+ if (event.key === 'Enter') {
177
+ prepareShowOverlay(event)
178
+ }
179
+ }}>
180
+ <ChevronRight />
181
+ </IconBox>
182
+ }
183
+ </li>
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Renders the overlay content.
189
+ * @param props the content of the overlay, can be either customized (react component), a config object or a function that creates the
190
+ * config object.
191
+ * @returns the content
192
+ */
193
+ const OverlayRenderer = ({ content, customContent }: Pick<MenuSection, 'content' | 'customContent'>) => {
194
+ if (customContent) {
195
+ return <div id="custom-selectable-item">{customContent}</div>
196
+ }
197
+
198
+ const data = typeof content === 'function' ? content() : content
199
+ return <MenuContent {...data} />
200
+ }
201
+
202
+ /**
203
+ * Renders a menu-sections interface.
204
+ *
205
+ * Considering the Stackspot UI, this is the "menu sections", not the "menu content", i.e. it's the first menu from left to right, the
206
+ * one with the icons and section names: the main menu.
207
+ * @param props the props for the component {@link MenuProps}.
208
+ */
209
+ export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
210
+ const Link = useAnchorTag()
211
+ const t = useTranslate(dictionary)
212
+ // this is a mock state only used to force an update on the component.
213
+ const [_, setUpdate] = useState(0)
214
+
215
+ const toggleMenu = useCallback((hasContent: boolean) => {
216
+ const layout = document.getElementById('layout')
217
+ if (!layout) return
218
+ if (layout.classList.contains('menu-compact')) {
219
+ layout.classList.remove('menu-compact')
220
+ } else {
221
+ layout.classList.add('menu-compact')
222
+ }
223
+
224
+ if (hasContent) {
225
+ if (layout.classList.contains('menu-content-visible')) {
226
+ layout.classList.remove('menu-content-visible')
227
+ } else {
228
+ layout.classList.add('menu-content-visible')
229
+ }
230
+ }
231
+ setUpdate(current => current + 1)
232
+ }, [])
233
+ // the current overlay showing, when the user hovers the section icon. This is the index of the item in the sections array.
234
+ const [currentOverlay, setCurrentOverlay] = useState<number | undefined>()
235
+
236
+ const sectionItems = useMemo(
237
+ () => sections.filter(s => !s.hidden).map((s, i) => <Section key={i} id={i} {...s} setCurrentOverlay={setCurrentOverlay}
238
+ hasContent={!!props.content || !!props.customContent} />),
239
+ [sections],
240
+ )
241
+
242
+ function onPressEscape() {
243
+ getAccessibilityButtonOfSectionWithActiveOverlay()?.focus()
244
+ hideOverlayImmediately()
245
+ }
246
+
247
+ const { keyboardControlledElement: overlayRef, attachKeyboardListeners, detachKeyboardListeners } = useKeyboardControls({
248
+ onPressEscape,
249
+ querySelectors: 'li a.action, #custom-selectable-item button, #custom-selectable-item input',
250
+ })
251
+
252
+ // this only works because we have a single section menu in the site. This workaround was created since the keyboard controls were
253
+ // transformed into a hook. This is not ideal and it would be a good idea to rewrite this code without the need for this.
254
+ attachKeyboardListenersForOverlay = attachKeyboardListeners
255
+ detachKeyboardListenersForOverlay = detachKeyboardListeners
256
+
257
+ /* This function renders the section preview in the overlay in normal circumstances. If the menu is hidden and the section is active,
258
+ instead of rendering the section preview, it will render the actual menu content, which would be invisible otherwise.
259
+ Below, the key is of extreme importance. It ensures React will consider every section content to be an entirely different
260
+ component. Without this, React would see the content changing every time a new section is hovered. Since the content might be a
261
+ hook, this would cause some serious problems. */
262
+ function renderMenuOverlay() {
263
+ if (currentOverlay === undefined) return null
264
+ const shouldRenderMenuContentInstead = !isMenuContentVisible() && sections[currentOverlay].active &&
265
+ (!!props.content || !!props.customContent)
266
+ return shouldRenderMenuContentInstead
267
+ ? <OverlayRenderer key={'contentKey' in props ? props.contentKey : undefined} content={props.content}
268
+ customContent={props.customContent} />
269
+ : <OverlayRenderer key={currentOverlay} content={sections[currentOverlay].content}
270
+ customContent={sections[currentOverlay].customContent} />
271
+ }
272
+
273
+ return (
274
+ <>
275
+ <ul>{sectionItems}</ul>
276
+
277
+ <Flex mb={7} alignItems="center">
278
+ <button className="toggle sections-footer" onClick={() => toggleMenu(!!props.content || !!props.customContent)}
279
+ title={t.toggle} tabIndex={-1}>
280
+ <IconBox>
281
+ <Expand className="expand" />
282
+ <Collapse className="collapse" />
283
+ </IconBox>
284
+ <Text appearance="microtext1" ml={8} className="collapse" colorScheme="light.contrastText">{t.hide}</Text>
285
+ </button>
286
+ {(props.settings?.show) &&
287
+ <Link href={props.settings?.href} onClick={props.settings?.onClick}
288
+ className="sections-footer toggle"
289
+ {...(props.settings.active ? { 'aria-current': 'page' } : undefined)}>
290
+ <IconBox aria-label={t.settingsIcon}>
291
+ <Cog />
292
+ </IconBox>
293
+ <Text appearance="microtext1" ml={8} className="collapse">{t.settings}</Text>
294
+ </Link>
295
+ }
296
+ </Flex>
297
+
298
+ <div id={MENU_OVERLAY_ID} onMouseEnter={showOverlay} onMouseLeave={hideOverlay} ref={overlayRef}>
299
+ {renderMenuOverlay()}
300
+ </div>
301
+ </>
302
+ )
303
+ }
304
+
305
+ const dictionary = {
306
+ en: {
307
+ toggle: 'Show or hide the menu',
308
+ menuOptions: 'View $0 menu options',
309
+ settings: 'Settings',
310
+ settingsIcon: 'Settings icon',
311
+ hide: 'Hide',
312
+ },
313
+ pt: {
314
+ toggle: 'Visualizar ou esconder o menu',
315
+ menuOptions: 'Visualizar opções do menu $0',
316
+ settings: 'Configurações',
317
+ settingsIcon: 'Ícone de configurações',
318
+ hide: 'Esconder',
319
+ },
320
+ } satisfies Dictionary