@stack-spot/portal-layout 0.0.11 → 0.0.12

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 (52) hide show
  1. package/dist/Layout.js +1 -1
  2. package/dist/Layout.js.map +1 -1
  3. package/dist/LayoutOverlayManager.d.ts +4 -0
  4. package/dist/LayoutOverlayManager.d.ts.map +1 -1
  5. package/dist/LayoutOverlayManager.js +51 -21
  6. package/dist/LayoutOverlayManager.js.map +1 -1
  7. package/dist/components/OverlayContent.d.ts +1 -0
  8. package/dist/components/OverlayContent.d.ts.map +1 -1
  9. package/dist/components/OverlayContent.js +2 -1
  10. package/dist/components/OverlayContent.js.map +1 -1
  11. package/dist/components/SelectionList.d.ts +2 -1
  12. package/dist/components/SelectionList.d.ts.map +1 -1
  13. package/dist/components/SelectionList.js +87 -30
  14. package/dist/components/SelectionList.js.map +1 -1
  15. package/dist/components/UserMenu.d.ts.map +1 -1
  16. package/dist/components/UserMenu.js +14 -3
  17. package/dist/components/UserMenu.js.map +1 -1
  18. package/dist/components/menu/MenuContent.d.ts.map +1 -1
  19. package/dist/components/menu/MenuContent.js +10 -6
  20. package/dist/components/menu/MenuContent.js.map +1 -1
  21. package/dist/components/menu/MenuSections.d.ts.map +1 -1
  22. package/dist/components/menu/MenuSections.js +12 -2
  23. package/dist/components/menu/MenuSections.js.map +1 -1
  24. package/dist/components/menu/PageSelector.d.ts.map +1 -1
  25. package/dist/components/menu/PageSelector.js +13 -2
  26. package/dist/components/menu/PageSelector.js.map +1 -1
  27. package/dist/elements.d.ts +13 -0
  28. package/dist/elements.d.ts.map +1 -0
  29. package/dist/elements.js +13 -0
  30. package/dist/elements.js.map +1 -0
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/layout.css +5 -1
  36. package/dist/utils.d.ts +45 -0
  37. package/dist/utils.d.ts.map +1 -1
  38. package/dist/utils.js +80 -0
  39. package/dist/utils.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/Layout.tsx +3 -3
  42. package/src/LayoutOverlayManager.tsx +54 -30
  43. package/src/components/OverlayContent.tsx +3 -1
  44. package/src/components/SelectionList.tsx +111 -28
  45. package/src/components/UserMenu.tsx +23 -3
  46. package/src/components/menu/MenuContent.tsx +18 -7
  47. package/src/components/menu/MenuSections.tsx +14 -1
  48. package/src/components/menu/PageSelector.tsx +24 -2
  49. package/src/elements.ts +19 -0
  50. package/src/index.ts +2 -1
  51. package/src/layout.css +5 -1
  52. package/src/utils.ts +94 -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', MODAL_ID)
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.setAttribute('class', `visible ${size}`)
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', BOTTOM_DIALOG_ID)
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?.setAttribute('class', 'visible')
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', RIGHT_PANEL_ID)
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?.classList?.add('visible')
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?.setAttribute('class', '')
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?.removeAttribute('class')
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?.setAttribute('class', '')
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: max-height ease-in ${ANIMATION_DURATION_MS / 1000}s;
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={onClick}>
126
+ <a href={href} onClick={handleClick} tabIndex={0}>
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: (current: CurrentItemList) => void) {
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={() => setCurrent({ items: children, label })}>
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,120 @@ function renderCollapsible({ label, icon, iconRight, children }: ListCollapsible
129
151
  )
130
152
  }
131
153
 
132
- function renderSection({ label, children }: ListSection, setCurrent: (current: CurrentItemList) => void) {
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, setCurrent))}</ul>
158
+ <ul>{children.map(i => renderItem(i, options))}</ul>
137
159
  </li>
138
160
  )
139
161
  }
140
162
 
141
- function renderItem(item: ListItem, setCurrent: (current: CurrentItemList) => void) {
163
+ function renderItem(item: ListItem, options: RenderOptions) {
142
164
  if ('children' in item) {
143
- return item.type === 'section' ? renderSection(item, setCurrent) : renderCollapsible(item, setCurrent)
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(i, (next: CurrentItemList) => setCurrent({ ...next, parent: current }))),
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
- const hide = useCallback((event: MouseEvent) => {
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() {
197
+ const anchors = getSelectableAnchors()
198
+ let i = 0
199
+ while (i < anchors.length && document.activeElement !== anchors[i]) i++
200
+ const next: any = event.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
+ },
215
+ ArrowUp: handleArrows,
216
+ ArrowDown: handleArrows,
217
+ }
218
+
219
+ handlers[event.key]?.()
220
+ }, [])
221
+
222
+ const hide = useCallback((event: Event) => {
159
223
  const target = (event.target as HTMLElement | null)
160
224
  // if the element is not in the DOM anymore, we'll consider the click was inside the selection list
161
225
  const isClickInsideSelectionList = !target?.isConnected || wrapper.current?.contains(target)
162
226
  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
- }
227
+ if (!isClickInsideSelectionList || isAction) onHide?.()
168
228
  }, [])
169
229
 
170
230
  useEffect(() => {
171
- if (!onHide) return
172
- if (visible) setTimeout(() => document.addEventListener('click', hide), 50)
231
+ if (visible) {
232
+ setCurrent({ items })
233
+ document.addEventListener('keydown', keyboardControls)
234
+ if (onHide) setTimeout(() => document.addEventListener('click', hide), 50)
235
+ }
236
+ else {
237
+ document.removeEventListener('keydown', keyboardControls)
238
+ document.removeEventListener('click', hide)
239
+ }
173
240
  }, [visible])
174
241
 
175
- useEffect(() => {
176
- itemsRef.current = items
177
- if (!wrapper.current?.classList.contains('visible')) setCurrent({ items })
178
- }, [items])
179
-
180
242
  return (
181
243
  <SelectionBox
244
+ id={id}
182
245
  ref={wrapper}
183
246
  $maxHeight={maxHeight}
184
247
  style={style}
185
248
  className={listToClass(['selection-list', visible ? 'visible' : undefined, className])}
186
249
  $scroll={scroll}
250
+ aria-hidden={!visible}
187
251
  >
188
252
  <div className="selection-list-content">
189
253
  {before}
190
254
  {current.parent
191
255
  ? (
192
256
  <Flex mt={5} mb={1} alignItems="center">
193
- <IconButton onClick={() => setCurrent(current.parent ?? { items })} sx={{ mr: 3 }}>
257
+ <IconButton
258
+ onClick={(ev) => {
259
+ // accessibility: this will tell the screen reader the section was collapsed before this button is removed from the DOM.
260
+ (ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'false')
261
+ setCurrent(current.parent ?? { items })
262
+ }}
263
+ sx={{ mr: 3 }}
264
+ title={t.back}
265
+ aria-controls={id}
266
+ aria-expanded={true}
267
+ >
194
268
  <ArrowLeft />
195
269
  </IconButton>
196
270
  <Text appearance="microtext1">{current.label}</Text>
@@ -204,3 +278,12 @@ export const SelectionList = ({
204
278
  </SelectionBox>
205
279
  )
206
280
  }
281
+
282
+ const dictionary = {
283
+ en: {
284
+ back: 'Go back',
285
+ },
286
+ pt: {
287
+ back: 'Voltar',
288
+ },
289
+ } 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 as="button" onClick={() => setVisible(true)}>
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 > li > a {
99
- height: 40px;
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 {
@@ -148,16 +151,24 @@ const ActionItem = ({ label, onClick, href, active, icon, badge, overflow = 'wra
148
151
  const CollapsibleGroupItem = ({ label, open: initiallyOpened, children, icon, badge, overflow = 'wrap' }: ItemGroup) => {
149
152
  const [open, setOpen] = useState(initiallyOpened ?? children?.some(c => 'active' in c && c.active) ?? false)
150
153
  const items = useMemo(() => children?.map(renderOption), [children])
154
+ const id = `menuGroup${label}`
151
155
 
152
156
  return (
153
157
  <>
154
- <a onClick={() => setOpen(!open)} className="item-row">
158
+ <a
159
+ onClick={() => setOpen(!open)}
160
+ onKeyDown={e => e.key === 'Enter' && setOpen(!open)}
161
+ className="item-row"
162
+ tabIndex={0}
163
+ aria-controls={id}
164
+ aria-expanded={open}
165
+ >
155
166
  {icon}
156
167
  <Text appearance="body2" className={`label ${overflow}`}>{label}</Text>
157
168
  {badge}
158
169
  <IconBox><ChevronDown className={listToClass(['chevron', open ? 'open' : ''])} /></IconBox>
159
170
  </a>
160
- <MenuGroup className={open ? 'open' : undefined}>{items}</MenuGroup>
171
+ <MenuGroup id={id} className={open ? 'open' : undefined} aria-hidden={!open}>{items}</MenuGroup>
161
172
  </>
162
173
  )
163
174
  }
@@ -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,8 @@ const Section = ({
84
85
  onClick={click}
85
86
  onMouseEnter={showOverlayAndFixArrowPosition}
86
87
  onMouseLeave={() => shouldShowOverlay() && hideOverlay()}
88
+ title={label}
89
+ aria-label={label}
87
90
  >
88
91
  {icon}
89
92
  <Text appearance="microtext1" className="section-label">{label}</Text>
@@ -98,6 +101,7 @@ const OverlayRenderer = ({ content }: Pick<MenuSection, 'content'>) => {
98
101
  }
99
102
 
100
103
  export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
104
+ const t = useTranslate(dictionary)
101
105
  // this is a mock state only used to force an update on the component.
102
106
  const [_, setUpdate] = useState(0)
103
107
  const toggleMenu = useCallback(() => {
@@ -134,7 +138,7 @@ export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
134
138
  return (
135
139
  <>
136
140
  <ul>{sectionItems}</ul>
137
- {!!props.content && <button className="toggle" onClick={toggleMenu} title="Toggle menu panel visibility">
141
+ {!!props.content && <button className="toggle" onClick={toggleMenu} title={t.toggle}>
138
142
  <IconBox>
139
143
  <MenuIcon className="expand" />
140
144
  <ChevronLeft className="collapse" />
@@ -147,3 +151,12 @@ export const MenuSections = ({ sections = [], ...props }: MenuProps) => {
147
151
  </>
148
152
  )
149
153
  }
154
+
155
+ const dictionary = {
156
+ en: {
157
+ toggle: 'Show or hide the menu',
158
+ },
159
+ pt: {
160
+ toggle: 'Visualizar ou esconder o menu',
161
+ },
162
+ } satisfies Dictionary
@@ -2,7 +2,8 @@ import { IconBox, Text } from '@citric/core'
2
2
  import { ArrowRight, Select } from '@citric/icons'
3
3
  import { LoadingCircular } from '@citric/ui'
4
4
  import { theme } from '@stack-spot/portal-theme'
5
- import { useMemo, useState } from 'react'
5
+ import { Dictionary, interpolate, useTranslate } from '@stack-spot/portal-translate'
6
+ import { useMemo, useRef, useState } from 'react'
6
7
  import { styled } from 'styled-components'
7
8
  import { ListAction, SelectionList } from '../SelectionList'
8
9
  import { MENU_CONTENT_PADDING as PADDING } from './constants'
@@ -76,7 +77,9 @@ const SelectorBox = styled.div`
76
77
  `
77
78
 
78
79
  export const PageSelector = ({ options, value, button, loading, title }: Selector) => {
80
+ const t = useTranslate(dictionary)
79
81
  const [visible, setVisible] = useState(false)
82
+ const id = useRef(`pageSelector${title || Math.random()}`)
80
83
  const { optionsWithIcon, selected } = useMemo(
81
84
  () => {
82
85
  let selected = options[0]
@@ -100,13 +103,22 @@ export const PageSelector = ({ options, value, button, loading, title }: Selecto
100
103
  : (
101
104
  <>
102
105
  {title && <Text colorScheme="light.700" sx={{ mb: 3 }}>{title}</Text>}
103
- <a onClick={() => setVisible(true)} aria-label={value}>
106
+ <a
107
+ onClick={() => setVisible(true)}
108
+ onKeyDown={e => e.key === 'Enter' && setVisible(true)}
109
+ title={value}
110
+ tabIndex={0}
111
+ aria-label={interpolate(t.accessibility, [value])}
112
+ aria-expanded={visible}
113
+ aria-controls={id.current}
114
+ >
104
115
  {selected?.icon && <IconBox>{selected?.icon}</IconBox>}
105
116
  <Text appearance="body2" className="label">{selected?.label ?? button?.label ?? value}</Text>
106
117
  <IconBox size="xs"><Select /></IconBox>
107
118
  </a>
108
119
 
109
120
  <SelectionList
121
+ id={id.current}
110
122
  visible={visible}
111
123
  items={optionsWithIcon}
112
124
  onHide={() => setVisible(false)}
@@ -119,3 +131,13 @@ export const PageSelector = ({ options, value, button, loading, title }: Selecto
119
131
  </SelectorBox>
120
132
  )
121
133
  }
134
+
135
+ const dictionary = {
136
+ en: {
137
+ accessibility: 'Current value: $0. Press Enter to change.',
138
+ },
139
+ pt: {
140
+ accessibility: 'Valor atual: $0. Aperte Enter para mudar.',
141
+ },
142
+ } satisfies Dictionary
143
+