@stack-spot/portal-layout 0.0.11 → 0.0.13
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.map +1 -1
- package/dist/Layout.js +2 -1
- package/dist/Layout.js.map +1 -1
- package/dist/LayoutOverlayManager.d.ts +4 -0
- package/dist/LayoutOverlayManager.d.ts.map +1 -1
- package/dist/LayoutOverlayManager.js +51 -21
- package/dist/LayoutOverlayManager.js.map +1 -1
- package/dist/components/OverlayContent.d.ts +1 -0
- package/dist/components/OverlayContent.d.ts.map +1 -1
- package/dist/components/OverlayContent.js +2 -1
- 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 +91 -30
- package/dist/components/SelectionList.js.map +1 -1
- package/dist/components/UserMenu.d.ts.map +1 -1
- package/dist/components/UserMenu.js +14 -3
- package/dist/components/UserMenu.js.map +1 -1
- package/dist/components/menu/MenuContent.d.ts.map +1 -1
- package/dist/components/menu/MenuContent.js +11 -7
- package/dist/components/menu/MenuContent.js.map +1 -1
- package/dist/components/menu/MenuSections.d.ts.map +1 -1
- package/dist/components/menu/MenuSections.js +12 -2
- package/dist/components/menu/MenuSections.js.map +1 -1
- package/dist/components/menu/PageSelector.d.ts.map +1 -1
- package/dist/components/menu/PageSelector.js +13 -2
- package/dist/components/menu/PageSelector.js.map +1 -1
- package/dist/elements.d.ts +18 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +18 -0
- package/dist/elements.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/layout.css +13 -1
- package/dist/utils.d.ts +51 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +93 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
- package/src/Layout.tsx +14 -11
- package/src/LayoutOverlayManager.tsx +54 -30
- package/src/components/OverlayContent.tsx +3 -1
- package/src/components/SelectionList.tsx +115 -28
- package/src/components/UserMenu.tsx +23 -3
- package/src/components/menu/MenuContent.tsx +19 -7
- package/src/components/menu/MenuSections.tsx +16 -1
- package/src/components/menu/PageSelector.tsx +24 -2
- package/src/elements.ts +24 -0
- package/src/index.ts +2 -1
- package/src/layout.css +13 -1
- package/src/utils.ts +107 -0
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
import { Button } from '@citric/core'
|
|
4
4
|
import { ReactElement, useLayoutEffect, useState } from 'react'
|
|
5
5
|
import { Dialog, DialogOptions } from './components/Dialog'
|
|
6
|
-
import { OverlayContent, OverlayContentProps } from './components/OverlayContent'
|
|
6
|
+
import { CLOSE_OVERLAY_ID, OverlayContent, OverlayContentProps } from './components/OverlayContent'
|
|
7
7
|
import { getDictionary } from './dictionary'
|
|
8
|
+
import { LayoutElements, elementIds, getLayoutElements } from './elements'
|
|
8
9
|
import { ElementNotFound, LayoutError } from './errors'
|
|
9
10
|
import { showToaster as showReactToaster } from './toaster'
|
|
10
|
-
import { valueOfLayoutVar } from './utils'
|
|
11
|
+
import { focusFirstChild, valueOfLayoutVar } from './utils'
|
|
11
12
|
|
|
12
13
|
interface AlertOptions extends Omit<DialogOptions, 'cancel'> {
|
|
13
14
|
showButton?: boolean,
|
|
@@ -16,14 +17,6 @@ interface AlertOptions extends Omit<DialogOptions, 'cancel'> {
|
|
|
16
17
|
type BottomDialogOptions = Omit<DialogOptions, 'title'>
|
|
17
18
|
type OverlaySize = 'small' | 'medium' | 'large'
|
|
18
19
|
type ModalSize = 'fit-content' | OverlaySize
|
|
19
|
-
|
|
20
|
-
interface LayoutElements {
|
|
21
|
-
backdrop: HTMLElement | null,
|
|
22
|
-
modal: HTMLElement | null,
|
|
23
|
-
rightPanel: HTMLElement | null,
|
|
24
|
-
bottomDialog: HTMLElement | null,
|
|
25
|
-
}
|
|
26
|
-
|
|
27
20
|
type SetContentFn = ((content: ReactElement | undefined) => void) | undefined
|
|
28
21
|
|
|
29
22
|
interface OverlayContentSetter {
|
|
@@ -42,11 +35,6 @@ interface CustomRightPanelOptions {
|
|
|
42
35
|
onClose?: () => void,
|
|
43
36
|
}
|
|
44
37
|
|
|
45
|
-
const BACKDROP_ID = 'backdrop'
|
|
46
|
-
const MODAL_ID = 'modal'
|
|
47
|
-
const BOTTOM_DIALOG_ID = 'bottomDialog'
|
|
48
|
-
const RIGHT_PANEL_ID = 'rightPanel'
|
|
49
|
-
|
|
50
38
|
class LayoutOverlayManager {
|
|
51
39
|
static readonly instance?: LayoutOverlayManager
|
|
52
40
|
private setContent: OverlayContentSetter = {}
|
|
@@ -54,16 +42,20 @@ class LayoutOverlayManager {
|
|
|
54
42
|
private onModalClose?: () => void
|
|
55
43
|
|
|
56
44
|
private setupElements() {
|
|
57
|
-
this.elements =
|
|
58
|
-
modal: document.getElementById(MODAL_ID),
|
|
59
|
-
backdrop: document.getElementById(BACKDROP_ID),
|
|
60
|
-
bottomDialog: document.getElementById(BOTTOM_DIALOG_ID),
|
|
61
|
-
rightPanel: document.getElementById(RIGHT_PANEL_ID),
|
|
62
|
-
}
|
|
45
|
+
this.elements = getLayoutElements()
|
|
63
46
|
this.elements.backdrop?.addEventListener('click', (event) => {
|
|
64
47
|
if (this.isModalOpen() && !this.elements?.modal?.contains?.(event.target as Node)) this.closeModal()
|
|
65
48
|
if (this.isRightPanelOpen() && !this.elements?.rightPanel?.contains?.(event.target as Node)) this.closeRightPanel()
|
|
66
49
|
})
|
|
50
|
+
this.elements.backdrop?.addEventListener('keydown', (event) => {
|
|
51
|
+
if (event.key !== 'Escape') return
|
|
52
|
+
if (this.isModalOpen()) this.closeModal()
|
|
53
|
+
if (this.isRightPanelOpen()) this.closeRightPanel()
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
})
|
|
56
|
+
this.setInteractivity(this.elements?.modal, false)
|
|
57
|
+
this.setInteractivity(this.elements?.rightPanel, false)
|
|
58
|
+
this.setInteractivity(this.elements?.bottomDialog, false)
|
|
67
59
|
}
|
|
68
60
|
|
|
69
61
|
// this should actually be like Kotlin's "internal", i.e. private to any user of the lib, but public to the lib itself.
|
|
@@ -81,6 +73,38 @@ class LayoutOverlayManager {
|
|
|
81
73
|
return { modal, rightPanel, bottomDialog }
|
|
82
74
|
}
|
|
83
75
|
|
|
76
|
+
private setInteractivity(element: HTMLElement | null | undefined, interactive: boolean) {
|
|
77
|
+
if (interactive) {
|
|
78
|
+
element?.removeAttribute('inert')
|
|
79
|
+
element?.removeAttribute('aria-hidden')
|
|
80
|
+
} else {
|
|
81
|
+
element?.setAttribute('aria-hidden', '')
|
|
82
|
+
element?.setAttribute('inert', '')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private setMainContentInteractivity(interactive: boolean) {
|
|
87
|
+
this.setInteractivity(this.elements?.page, interactive)
|
|
88
|
+
this.setInteractivity(this.elements?.header, interactive)
|
|
89
|
+
this.setInteractivity(this.elements?.menu, interactive)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private showOverlay(element: HTMLElement | null | undefined, extraClasses: string[] = []) {
|
|
93
|
+
element?.classList.add('visible', ...extraClasses)
|
|
94
|
+
this.setInteractivity(element, true)
|
|
95
|
+
this.setMainContentInteractivity(false)
|
|
96
|
+
setTimeout(() => focusFirstChild(
|
|
97
|
+
element,
|
|
98
|
+
{ priority: [['input', 'textarea', 'select', 'other', 'button']], ignore: `#${CLOSE_OVERLAY_ID}` },
|
|
99
|
+
), 50)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private hideOverlay(element: HTMLElement | null | undefined) {
|
|
103
|
+
element?.setAttribute('class', '')
|
|
104
|
+
this.setInteractivity(element, false)
|
|
105
|
+
this.setMainContentInteractivity(true)
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
isModalOpen() {
|
|
85
109
|
return this.elements?.modal?.classList.contains('visible') ?? false
|
|
86
110
|
}
|
|
@@ -94,12 +118,12 @@ class LayoutOverlayManager {
|
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
showCustomModal(content: React.ReactElement, { size = 'medium', onClose }: CustomModalOptions = {}) {
|
|
97
|
-
if (!this.elements?.modal) throw new ElementNotFound('modal',
|
|
121
|
+
if (!this.elements?.modal) throw new ElementNotFound('modal', elementIds.modal)
|
|
98
122
|
if (!this.setContent.modal) throw new LayoutError('unable to show modal, because it has not been setup yet.')
|
|
99
123
|
this.onModalClose = onClose
|
|
100
124
|
this.setContent.modal(content)
|
|
101
125
|
this.elements.backdrop?.setAttribute('class', 'visible')
|
|
102
|
-
this.elements.modal
|
|
126
|
+
this.showOverlay(this.elements.modal, [size])
|
|
103
127
|
}
|
|
104
128
|
|
|
105
129
|
showModal({ size, ...props }: OverlayContentProps & { size?: ModalSize }) {
|
|
@@ -138,7 +162,7 @@ class LayoutOverlayManager {
|
|
|
138
162
|
}
|
|
139
163
|
|
|
140
164
|
showBottomDialog({ message, cancel, confirm }: BottomDialogOptions): Promise<boolean> {
|
|
141
|
-
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog',
|
|
165
|
+
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog', elementIds.bottomDialog)
|
|
142
166
|
if (!this.setContent.bottomDialog) throw new LayoutError('unable to show bottom dialog, because it has not been setup yet.')
|
|
143
167
|
return new Promise((resolve) => {
|
|
144
168
|
this.setContent.bottomDialog?.(
|
|
@@ -150,19 +174,19 @@ class LayoutOverlayManager {
|
|
|
150
174
|
</div>
|
|
151
175
|
</>,
|
|
152
176
|
)
|
|
153
|
-
this.elements?.bottomDialog
|
|
177
|
+
this.showOverlay(this.elements?.bottomDialog)
|
|
154
178
|
})
|
|
155
179
|
}
|
|
156
180
|
|
|
157
181
|
showCustomRightPanel(content: ReactElement, { size = 'medium', onClose }: CustomRightPanelOptions = {}) {
|
|
158
|
-
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay',
|
|
182
|
+
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay', elementIds.rightPanel)
|
|
159
183
|
if (!this.setContent.rightPanel) throw new LayoutError('unable to show right panel overlay, because it has not been setup yet.')
|
|
160
184
|
this.onModalClose = onClose
|
|
161
185
|
this.setContent.rightPanel(content)
|
|
162
186
|
this.elements?.rightPanel.classList.add(size)
|
|
163
187
|
setTimeout(() => {
|
|
164
188
|
this.elements?.backdrop?.setAttribute('class', 'visible')
|
|
165
|
-
this.elements?.rightPanel
|
|
189
|
+
this.showOverlay(this.elements?.rightPanel)
|
|
166
190
|
})
|
|
167
191
|
}
|
|
168
192
|
|
|
@@ -183,7 +207,7 @@ class LayoutOverlayManager {
|
|
|
183
207
|
setTimeout(
|
|
184
208
|
() => {
|
|
185
209
|
if (this.setContent.modal) this.setContent.modal(undefined)
|
|
186
|
-
this.elements?.modal
|
|
210
|
+
this.hideOverlay(this.elements?.modal)
|
|
187
211
|
},
|
|
188
212
|
parseFloat(valueOfLayoutVar('--modal-animation-duration')) * 1000,
|
|
189
213
|
)
|
|
@@ -199,14 +223,14 @@ class LayoutOverlayManager {
|
|
|
199
223
|
setTimeout(
|
|
200
224
|
() => {
|
|
201
225
|
if (this.setContent.rightPanel) this.setContent.rightPanel(undefined)
|
|
202
|
-
this.elements?.rightPanel
|
|
226
|
+
this.hideOverlay(this.elements?.rightPanel)
|
|
203
227
|
},
|
|
204
228
|
parseFloat(valueOfLayoutVar('--right-panel-animation-duration')) * 1000,
|
|
205
229
|
)
|
|
206
230
|
}
|
|
207
231
|
|
|
208
232
|
closeBottomDialog() {
|
|
209
|
-
this.elements?.bottomDialog
|
|
233
|
+
this.hideOverlay(this.elements?.bottomDialog)
|
|
210
234
|
}
|
|
211
235
|
|
|
212
236
|
isInsideModal(element: HTMLElement) {
|
|
@@ -6,6 +6,8 @@ import { ReactNode } from 'react'
|
|
|
6
6
|
import { styled } from 'styled-components'
|
|
7
7
|
import { useDictionary } from '../dictionary'
|
|
8
8
|
|
|
9
|
+
export const CLOSE_OVERLAY_ID = 'close-overlay'
|
|
10
|
+
|
|
9
11
|
export interface OverlayContentProps extends WithStyle {
|
|
10
12
|
title: string,
|
|
11
13
|
subtitle?: string,
|
|
@@ -48,7 +50,7 @@ export const OverlayContent = ({ children, title, subtitle, className, style, on
|
|
|
48
50
|
<Text appearance={type === 'modal' ? 'h3' : 'h4'}>{title}</Text>
|
|
49
51
|
{subtitle && <Text appearance="body2" colorScheme="light.700">{subtitle}</Text>}
|
|
50
52
|
</Flex>
|
|
51
|
-
<IconButton onClick={onClose} title={t.close} aria-label={t.close}><TimesMini /></IconButton>
|
|
53
|
+
<IconButton onClick={onClose} title={t.close} aria-label={t.close} id={CLOSE_OVERLAY_ID}><TimesMini /></IconButton>
|
|
52
54
|
</header>
|
|
53
55
|
{children}
|
|
54
56
|
</ContentBox>
|
|
@@ -2,6 +2,7 @@ import { Flex, IconBox, Text } from '@citric/core'
|
|
|
2
2
|
import { ArrowLeft, Check, ChevronRight } from '@citric/icons'
|
|
3
3
|
import { IconButton } from '@citric/ui'
|
|
4
4
|
import { WithStyle, listToClass, theme } from '@stack-spot/portal-theme'
|
|
5
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
5
6
|
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
7
|
import { styled } from 'styled-components'
|
|
7
8
|
import { Action } from './types'
|
|
@@ -39,8 +40,10 @@ interface CurrentItemList {
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
const ANIMATION_DURATION_MS = 300
|
|
43
|
+
const MAX_HEIGHT_TRANSITION = `max-height ease-in ${ANIMATION_DURATION_MS / 1000}s`
|
|
42
44
|
|
|
43
45
|
export interface SelectionListProps extends WithStyle {
|
|
46
|
+
id?: string,
|
|
44
47
|
visible?: boolean,
|
|
45
48
|
items: ListItem[],
|
|
46
49
|
onHide?: () => void,
|
|
@@ -50,14 +53,21 @@ export interface SelectionListProps extends WithStyle {
|
|
|
50
53
|
scroll?: boolean,
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
interface RenderOptions {
|
|
57
|
+
setCurrent: (current: CurrentItemList) => void,
|
|
58
|
+
controllerId?: string,
|
|
59
|
+
onClose?: () => void,
|
|
60
|
+
}
|
|
61
|
+
|
|
53
62
|
const SelectionBox = styled.div<{ $maxHeight: string, $scroll?: boolean }>`
|
|
54
63
|
max-height: 0;
|
|
55
64
|
overflow-y: ${({ $scroll }) => $scroll ? 'auto' : 'hidden'};
|
|
56
65
|
overflow-x: hidden;
|
|
57
|
-
transition:
|
|
66
|
+
transition: ${MAX_HEIGHT_TRANSITION}, visibility 0s ${ANIMATION_DURATION_MS / 1000}s;
|
|
58
67
|
z-index: 1;
|
|
59
68
|
box-shadow: 4px 4px 48px #000;
|
|
60
69
|
border-radius: 0.5rem;
|
|
70
|
+
visibility: hidden;
|
|
61
71
|
|
|
62
72
|
.selection-list-content {
|
|
63
73
|
display: flex;
|
|
@@ -80,7 +90,7 @@ const SelectionBox = styled.div<{ $maxHeight: string, $scroll?: boolean }>`
|
|
|
80
90
|
li > a {
|
|
81
91
|
gap: 4px;
|
|
82
92
|
transition: background-color 0.2s;
|
|
83
|
-
&:hover {
|
|
93
|
+
&:hover, &:focus {
|
|
84
94
|
background: ${theme.color.light['400']};
|
|
85
95
|
}
|
|
86
96
|
.label {
|
|
@@ -100,13 +110,20 @@ const SelectionBox = styled.div<{ $maxHeight: string, $scroll?: boolean }>`
|
|
|
100
110
|
|
|
101
111
|
&.visible {
|
|
102
112
|
max-height: ${({ $maxHeight }) => $maxHeight};
|
|
113
|
+
visibility: visible;
|
|
114
|
+
transition: ${MAX_HEIGHT_TRANSITION};
|
|
103
115
|
}
|
|
104
116
|
`
|
|
105
117
|
|
|
106
|
-
function renderAction({ label, href, onClick, icon, iconRight, active }: ListAction) {
|
|
118
|
+
function renderAction({ label, href, onClick, icon, iconRight, active }: ListAction, { onClose }: RenderOptions) {
|
|
119
|
+
function handleClick() {
|
|
120
|
+
onClick?.()
|
|
121
|
+
onClose?.()
|
|
122
|
+
}
|
|
123
|
+
|
|
107
124
|
return (
|
|
108
125
|
<li key={label} className="action">
|
|
109
|
-
<a href={href} onClick={
|
|
126
|
+
<a href={href} onClick={handleClick} tabIndex={0} aria-selected={active}>
|
|
110
127
|
{icon && <IconBox>{icon}</IconBox>}
|
|
111
128
|
<Text appearance="body2" className="label">{label}</Text>
|
|
112
129
|
{iconRight && <IconBox>{iconRight}</IconBox>}
|
|
@@ -116,10 +133,15 @@ function renderAction({ label, href, onClick, icon, iconRight, active }: ListAct
|
|
|
116
133
|
)
|
|
117
134
|
}
|
|
118
135
|
|
|
119
|
-
function renderCollapsible({ label, icon, iconRight, children }: ListCollapsible, setCurrent
|
|
136
|
+
function renderCollapsible({ label, icon, iconRight, children }: ListCollapsible, { setCurrent, controllerId }: RenderOptions) {
|
|
137
|
+
function handleClick(ev: React.MouseEvent) {
|
|
138
|
+
// accessibility: this will tell the screen reader the section was expanded before this link is removed from the DOM.
|
|
139
|
+
(ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'true')
|
|
140
|
+
setCurrent({ items: children, label })
|
|
141
|
+
}
|
|
120
142
|
return (
|
|
121
143
|
<li key={label} className="collapsible">
|
|
122
|
-
<a onClick={
|
|
144
|
+
<a onClick={handleClick} tabIndex={0} aria-expanded={false} aria-controls={controllerId}>
|
|
123
145
|
{icon && <IconBox>{icon}</IconBox>}
|
|
124
146
|
<Text appearance="body2" className="label">{label}</Text>
|
|
125
147
|
{iconRight && <IconBox>{iconRight}</IconBox>}
|
|
@@ -129,68 +151,124 @@ function renderCollapsible({ label, icon, iconRight, children }: ListCollapsible
|
|
|
129
151
|
)
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
function renderSection({ label, children }: ListSection,
|
|
154
|
+
function renderSection({ label, children }: ListSection, options: RenderOptions) {
|
|
133
155
|
return (
|
|
134
156
|
<li key={label ?? children.map(c => c.label).join('-')} className="section">
|
|
135
157
|
{label && <Text appearance="overheader2" colorScheme="primary" className="section-title">{label}</Text>}
|
|
136
|
-
<ul>{children.map(i => renderItem(i,
|
|
158
|
+
<ul>{children.map(i => renderItem(i, options))}</ul>
|
|
137
159
|
</li>
|
|
138
160
|
)
|
|
139
161
|
}
|
|
140
162
|
|
|
141
|
-
function renderItem(item: ListItem,
|
|
163
|
+
function renderItem(item: ListItem, options: RenderOptions) {
|
|
142
164
|
if ('children' in item) {
|
|
143
|
-
return item.type === 'section' ? renderSection(item,
|
|
165
|
+
return item.type === 'section' ? renderSection(item, options) : renderCollapsible(item, options)
|
|
144
166
|
}
|
|
145
|
-
return renderAction(item)
|
|
167
|
+
return renderAction(item, options)
|
|
146
168
|
}
|
|
147
169
|
|
|
148
170
|
export const SelectionList = ({
|
|
149
|
-
items, className, style, visible = true, maxHeight = '300px', onHide, before, after, scroll,
|
|
171
|
+
id, items, className, style, visible = true, maxHeight = '300px', onHide, before, after, scroll,
|
|
150
172
|
}: SelectionListProps) => {
|
|
173
|
+
const t = useTranslate(dictionary)
|
|
151
174
|
const wrapper = useRef<HTMLDivElement>(null)
|
|
152
|
-
const itemsRef = useRef(items)
|
|
153
175
|
const [current, setCurrent] = useState<CurrentItemList>({ items })
|
|
176
|
+
|
|
154
177
|
const listItems = useMemo(
|
|
155
|
-
() => current.items.map(i => renderItem(
|
|
178
|
+
() => current.items.map(i => renderItem(
|
|
179
|
+
i,
|
|
180
|
+
{
|
|
181
|
+
setCurrent: (next: CurrentItemList) => setCurrent({ ...next, parent: current }),
|
|
182
|
+
onClose: onHide,
|
|
183
|
+
controllerId: id,
|
|
184
|
+
},
|
|
185
|
+
)),
|
|
156
186
|
[current],
|
|
157
187
|
)
|
|
158
|
-
|
|
188
|
+
|
|
189
|
+
const keyboardControls = useCallback((event: KeyboardEvent) => {
|
|
190
|
+
const target = event?.target as HTMLElement | null
|
|
191
|
+
|
|
192
|
+
function getSelectableAnchors() {
|
|
193
|
+
return wrapper.current?.querySelectorAll('li.action a, li.collapsible a, button') ?? []
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function handleArrows(key = event.key) {
|
|
197
|
+
const anchors = getSelectableAnchors()
|
|
198
|
+
let i = 0
|
|
199
|
+
while (i < anchors.length && document.activeElement !== anchors[i]) i++
|
|
200
|
+
const next: any = key === 'ArrowDown' ? (anchors[i + 1] ?? anchors[0]) : (anchors[i - 1] ?? anchors[anchors.length - 1])
|
|
201
|
+
next?.focus?.()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const handlers: Record<string, (() => void) | undefined> = {
|
|
205
|
+
Escape: () => {
|
|
206
|
+
onHide?.()
|
|
207
|
+
event.stopPropagation()
|
|
208
|
+
event.preventDefault()
|
|
209
|
+
},
|
|
210
|
+
Enter: () => target?.click(),
|
|
211
|
+
Tab: () => {
|
|
212
|
+
const anchors = getSelectableAnchors()
|
|
213
|
+
if (document.activeElement === anchors[anchors.length - 1]) onHide?.()
|
|
214
|
+
else {
|
|
215
|
+
handleArrows('ArrowDown')
|
|
216
|
+
event.preventDefault()
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
ArrowUp: handleArrows,
|
|
220
|
+
ArrowDown: handleArrows,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
handlers[event.key]?.()
|
|
224
|
+
}, [])
|
|
225
|
+
|
|
226
|
+
const hide = useCallback((event: Event) => {
|
|
159
227
|
const target = (event.target as HTMLElement | null)
|
|
160
228
|
// if the element is not in the DOM anymore, we'll consider the click was inside the selection list
|
|
161
229
|
const isClickInsideSelectionList = !target?.isConnected || wrapper.current?.contains(target)
|
|
162
230
|
const isAction = target?.classList?.contains('action') || !!target?.closest('.action')
|
|
163
|
-
if (!isClickInsideSelectionList || isAction)
|
|
164
|
-
if (onHide) onHide()
|
|
165
|
-
setTimeout(() => setCurrent({ items: itemsRef.current }), ANIMATION_DURATION_MS)
|
|
166
|
-
document.removeEventListener('click', hide)
|
|
167
|
-
}
|
|
231
|
+
if (!isClickInsideSelectionList || isAction) onHide?.()
|
|
168
232
|
}, [])
|
|
169
233
|
|
|
170
234
|
useEffect(() => {
|
|
171
|
-
if (
|
|
172
|
-
|
|
235
|
+
if (visible) {
|
|
236
|
+
setCurrent({ items })
|
|
237
|
+
document.addEventListener('keydown', keyboardControls)
|
|
238
|
+
if (onHide) setTimeout(() => document.addEventListener('click', hide), 50)
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
document.removeEventListener('keydown', keyboardControls)
|
|
242
|
+
document.removeEventListener('click', hide)
|
|
243
|
+
}
|
|
173
244
|
}, [visible])
|
|
174
245
|
|
|
175
|
-
useEffect(() => {
|
|
176
|
-
itemsRef.current = items
|
|
177
|
-
if (!wrapper.current?.classList.contains('visible')) setCurrent({ items })
|
|
178
|
-
}, [items])
|
|
179
|
-
|
|
180
246
|
return (
|
|
181
247
|
<SelectionBox
|
|
248
|
+
id={id}
|
|
182
249
|
ref={wrapper}
|
|
183
250
|
$maxHeight={maxHeight}
|
|
184
251
|
style={style}
|
|
185
252
|
className={listToClass(['selection-list', visible ? 'visible' : undefined, className])}
|
|
186
253
|
$scroll={scroll}
|
|
254
|
+
aria-hidden={!visible}
|
|
187
255
|
>
|
|
188
256
|
<div className="selection-list-content">
|
|
189
257
|
{before}
|
|
190
258
|
{current.parent
|
|
191
259
|
? (
|
|
192
260
|
<Flex mt={5} mb={1} alignItems="center">
|
|
193
|
-
<IconButton
|
|
261
|
+
<IconButton
|
|
262
|
+
onClick={(ev) => {
|
|
263
|
+
// accessibility: this will tell the screen reader the section was collapsed before this button is removed from the DOM.
|
|
264
|
+
(ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'false')
|
|
265
|
+
setCurrent(current.parent ?? { items })
|
|
266
|
+
}}
|
|
267
|
+
sx={{ mr: 3 }}
|
|
268
|
+
title={t.back}
|
|
269
|
+
aria-controls={id}
|
|
270
|
+
aria-expanded={true}
|
|
271
|
+
>
|
|
194
272
|
<ArrowLeft />
|
|
195
273
|
</IconButton>
|
|
196
274
|
<Text appearance="microtext1">{current.label}</Text>
|
|
@@ -204,3 +282,12 @@ export const SelectionList = ({
|
|
|
204
282
|
</SelectionBox>
|
|
205
283
|
)
|
|
206
284
|
}
|
|
285
|
+
|
|
286
|
+
const dictionary = {
|
|
287
|
+
en: {
|
|
288
|
+
back: 'Go back',
|
|
289
|
+
},
|
|
290
|
+
pt: {
|
|
291
|
+
back: 'Voltar',
|
|
292
|
+
},
|
|
293
|
+
} satisfies Dictionary
|
|
@@ -2,6 +2,7 @@ import { Flex, IconBox, LinkBox, Text } from '@citric/core'
|
|
|
2
2
|
import { ChevronDown } from '@citric/icons'
|
|
3
3
|
import { Avatar } from '@citric/ui'
|
|
4
4
|
import { theme } from '@stack-spot/portal-theme'
|
|
5
|
+
import { Dictionary, interpolate, useTranslate } from '@stack-spot/portal-translate'
|
|
5
6
|
import { useState } from 'react'
|
|
6
7
|
import { styled } from 'styled-components'
|
|
7
8
|
import { SelectionList, SelectionListProps } from './SelectionList'
|
|
@@ -12,6 +13,8 @@ interface Props {
|
|
|
12
13
|
options?: SelectionListProps['items'],
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const USER_MENU_ID = 'userMenu'
|
|
17
|
+
|
|
15
18
|
const UserMenuBox = styled.div`
|
|
16
19
|
.user-menu-header {
|
|
17
20
|
display: flex;
|
|
@@ -38,7 +41,7 @@ const UserMenuBox = styled.div`
|
|
|
38
41
|
margin: 8px 0;
|
|
39
42
|
& > a {
|
|
40
43
|
border-radius: 6px;
|
|
41
|
-
&:hover {
|
|
44
|
+
&:hover, &:focus {
|
|
42
45
|
background: ${theme.color.light['500']};
|
|
43
46
|
}
|
|
44
47
|
}
|
|
@@ -63,13 +66,20 @@ const UserMenuHeader = ({ userName, email }: Omit<Props, 'options'>) => (
|
|
|
63
66
|
)
|
|
64
67
|
|
|
65
68
|
export const UserMenu = ({ userName, email, options }: Props) => {
|
|
69
|
+
const t = useTranslate(dictionary)
|
|
66
70
|
const [visible, setVisible] = useState(false)
|
|
67
71
|
|
|
68
72
|
return (
|
|
69
73
|
<UserMenuBox>
|
|
70
|
-
<LinkBox
|
|
74
|
+
<LinkBox
|
|
75
|
+
as="button"
|
|
76
|
+
onClick={() => setVisible(v => !v)}
|
|
77
|
+
aria-controls={USER_MENU_ID}
|
|
78
|
+
aria-expanded={visible}
|
|
79
|
+
aria-label={interpolate(t.accountMenu, [userName])}
|
|
80
|
+
>
|
|
71
81
|
<Flex alignItems="center">
|
|
72
|
-
<Avatar size="xs">{userName}</Avatar>
|
|
82
|
+
<Avatar size="xs" aria-label={interpolate(t.accountMenu, [userName])}>{userName}</Avatar>
|
|
73
83
|
<IconBox colorScheme="inverse" className="chevron" style={visible ? { transform: 'rotate(180deg)' } : undefined}>
|
|
74
84
|
<ChevronDown />
|
|
75
85
|
</IconBox>
|
|
@@ -78,6 +88,7 @@ export const UserMenu = ({ userName, email, options }: Props) => {
|
|
|
78
88
|
|
|
79
89
|
{options?.length
|
|
80
90
|
? <SelectionList
|
|
91
|
+
id={USER_MENU_ID}
|
|
81
92
|
visible={visible}
|
|
82
93
|
before={<UserMenuHeader userName={userName} email={email} />}
|
|
83
94
|
items={options!}
|
|
@@ -89,3 +100,12 @@ export const UserMenu = ({ userName, email, options }: Props) => {
|
|
|
89
100
|
</UserMenuBox>
|
|
90
101
|
)
|
|
91
102
|
}
|
|
103
|
+
|
|
104
|
+
const dictionary = {
|
|
105
|
+
en: {
|
|
106
|
+
accountMenu: 'Profile menu of $0',
|
|
107
|
+
},
|
|
108
|
+
pt: {
|
|
109
|
+
accountMenu: 'Menu do perfil $0',
|
|
110
|
+
},
|
|
111
|
+
} satisfies Dictionary
|
|
@@ -23,6 +23,8 @@ const MenuGroup = styled.ul`
|
|
|
23
23
|
padding: 0 0 0 16px;
|
|
24
24
|
display: flex;
|
|
25
25
|
flex-direction: column;
|
|
26
|
+
visibility: hidden;
|
|
27
|
+
transition: visibility 0s 0.3s;
|
|
26
28
|
|
|
27
29
|
&.no-indentation {
|
|
28
30
|
padding: 0;
|
|
@@ -50,12 +52,9 @@ const MenuGroup = styled.ul`
|
|
|
50
52
|
position: relative;
|
|
51
53
|
height: 0;
|
|
52
54
|
overflow: hidden;
|
|
53
|
-
/* display: flex; */
|
|
54
|
-
/* align-items: center; */
|
|
55
55
|
transition: height 0.3s, background-color 0.2s;
|
|
56
56
|
margin: 0 ${PADDING - ITEM_PADDING}px;
|
|
57
57
|
border-radius: 4px;
|
|
58
|
-
/* justify-content: space-between; */
|
|
59
58
|
padding: 0 ${ITEM_PADDING}px;
|
|
60
59
|
|
|
61
60
|
&:hover {
|
|
@@ -95,8 +94,12 @@ const MenuGroup = styled.ul`
|
|
|
95
94
|
}
|
|
96
95
|
}
|
|
97
96
|
|
|
98
|
-
&.open
|
|
99
|
-
|
|
97
|
+
&.open {
|
|
98
|
+
visibility: visible;
|
|
99
|
+
transition: unset;
|
|
100
|
+
& > li > a {
|
|
101
|
+
height: 40px;
|
|
102
|
+
}
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
&:not(.open) &.open > li > a {
|
|
@@ -138,6 +141,7 @@ const ActionItem = ({ label, onClick, href, active, icon, badge, overflow = 'wra
|
|
|
138
141
|
hideOverlayImmediately()
|
|
139
142
|
}}
|
|
140
143
|
className={listToClass(['action', 'item-row', active ? 'active' : undefined])}
|
|
144
|
+
{...(active ? { 'aria-current': 'page' } : undefined)}
|
|
141
145
|
>
|
|
142
146
|
{icon}
|
|
143
147
|
<Text appearance="body2" className={`label ${overflow}`}>{label}</Text>
|
|
@@ -148,16 +152,24 @@ const ActionItem = ({ label, onClick, href, active, icon, badge, overflow = 'wra
|
|
|
148
152
|
const CollapsibleGroupItem = ({ label, open: initiallyOpened, children, icon, badge, overflow = 'wrap' }: ItemGroup) => {
|
|
149
153
|
const [open, setOpen] = useState(initiallyOpened ?? children?.some(c => 'active' in c && c.active) ?? false)
|
|
150
154
|
const items = useMemo(() => children?.map(renderOption), [children])
|
|
155
|
+
const id = `menuGroup${label}`
|
|
151
156
|
|
|
152
157
|
return (
|
|
153
158
|
<>
|
|
154
|
-
<a
|
|
159
|
+
<a
|
|
160
|
+
onClick={() => setOpen(!open)}
|
|
161
|
+
onKeyDown={e => e.key === 'Enter' && setOpen(!open)}
|
|
162
|
+
className="item-row"
|
|
163
|
+
tabIndex={0}
|
|
164
|
+
aria-controls={id}
|
|
165
|
+
aria-expanded={open}
|
|
166
|
+
>
|
|
155
167
|
{icon}
|
|
156
168
|
<Text appearance="body2" className={`label ${overflow}`}>{label}</Text>
|
|
157
169
|
{badge}
|
|
158
170
|
<IconBox><ChevronDown className={listToClass(['chevron', open ? 'open' : ''])} /></IconBox>
|
|
159
171
|
</a>
|
|
160
|
-
<MenuGroup className={open ? 'open' : undefined}>{items}</MenuGroup>
|
|
172
|
+
<MenuGroup id={id} className={open ? 'open' : undefined} aria-hidden={!open}>{items}</MenuGroup>
|
|
161
173
|
</>
|
|
162
174
|
)
|
|
163
175
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
2
|
import { IconBox, Text } from '@citric/core'
|
|
3
3
|
import { ChevronLeft, Menu as MenuIcon } from '@citric/icons'
|
|
4
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
4
5
|
import { useCallback, useMemo, useState } from 'react'
|
|
5
6
|
import { MenuContent } from './MenuContent'
|
|
6
7
|
import { MenuProps, MenuSection } from './types'
|
|
@@ -84,6 +85,10 @@ const Section = ({
|
|
|
84
85
|
onClick={click}
|
|
85
86
|
onMouseEnter={showOverlayAndFixArrowPosition}
|
|
86
87
|
onMouseLeave={() => shouldShowOverlay() && hideOverlay()}
|
|
88
|
+
title={label}
|
|
89
|
+
aria-label={label}
|
|
90
|
+
onKeyDown={onClick ? e => e.key === 'Enter' && onClick() : undefined}
|
|
91
|
+
{...(active ? { 'aria-current': 'page' } : undefined)}
|
|
87
92
|
>
|
|
88
93
|
{icon}
|
|
89
94
|
<Text appearance="microtext1" className="section-label">{label}</Text>
|
|
@@ -98,6 +103,7 @@ const OverlayRenderer = ({ content }: Pick<MenuSection, 'content'>) => {
|
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
|
|
106
|
+
const t = useTranslate(dictionary)
|
|
101
107
|
// this is a mock state only used to force an update on the component.
|
|
102
108
|
const [_, setUpdate] = useState(0)
|
|
103
109
|
const toggleMenu = useCallback(() => {
|
|
@@ -134,7 +140,7 @@ export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
|
|
|
134
140
|
return (
|
|
135
141
|
<>
|
|
136
142
|
<ul>{sectionItems}</ul>
|
|
137
|
-
{!!props.content && <button className="toggle" onClick={toggleMenu} title=
|
|
143
|
+
{!!props.content && <button className="toggle" onClick={toggleMenu} title={t.toggle} tabIndex={-1} aria-hidden>
|
|
138
144
|
<IconBox>
|
|
139
145
|
<MenuIcon className="expand" />
|
|
140
146
|
<ChevronLeft className="collapse" />
|
|
@@ -147,3 +153,12 @@ export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
|
|
|
147
153
|
</>
|
|
148
154
|
)
|
|
149
155
|
}
|
|
156
|
+
|
|
157
|
+
const dictionary = {
|
|
158
|
+
en: {
|
|
159
|
+
toggle: 'Show or hide the menu',
|
|
160
|
+
},
|
|
161
|
+
pt: {
|
|
162
|
+
toggle: 'Visualizar ou esconder o menu',
|
|
163
|
+
},
|
|
164
|
+
} satisfies Dictionary
|