@stack-spot/portal-layout 0.0.52 → 0.0.53
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 +12 -10
- 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 +466 -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 +2 -2
- package/dist/toaster.js.map +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 +466 -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,272 +1,272 @@
|
|
|
1
|
-
import { Flex, IconBox, Text } from '@citric/core'
|
|
2
|
-
import { ArrowLeft, Check, ChevronRight } from '@citric/icons'
|
|
3
|
-
import { IconButton } from '@citric/ui'
|
|
4
|
-
import { WithStyle, listToClass, theme } from '@stack-spot/portal-theme'
|
|
5
|
-
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
6
|
-
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
|
|
7
|
-
import { styled } from 'styled-components'
|
|
8
|
-
import { AnchorComponent, useAnchorTag } from '../layout-context'
|
|
9
|
-
import { useKeyboardControls } from './menu/use-keyboard-controls'
|
|
10
|
-
import { Action } from './types'
|
|
11
|
-
|
|
12
|
-
interface ItemWithIcon {
|
|
13
|
-
icon?: React.ReactElement,
|
|
14
|
-
iconRight?: React.ReactElement,
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface ListAction extends ItemWithIcon, Action {
|
|
18
|
-
active?: boolean,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface ListGroup {
|
|
22
|
-
type?: 'section' | 'collapsible',
|
|
23
|
-
children: ListItem[],
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ListSection extends ListGroup {
|
|
27
|
-
type: 'section',
|
|
28
|
-
label?: string,
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ListCollapsible extends ListGroup, ItemWithIcon {
|
|
32
|
-
type?: 'collapsible',
|
|
33
|
-
label: string,
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
type ListItem = ListSection | ListCollapsible | ListAction
|
|
37
|
-
|
|
38
|
-
interface CurrentItemList {
|
|
39
|
-
items: ListItem[],
|
|
40
|
-
label?: string,
|
|
41
|
-
parent?: CurrentItemList,
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const ANIMATION_DURATION_MS = 300
|
|
45
|
-
const MAX_HEIGHT_TRANSITION = `max-height ease-in ${ANIMATION_DURATION_MS / 1000}s`
|
|
46
|
-
|
|
47
|
-
export interface SelectionListProps extends WithStyle {
|
|
48
|
-
id: string,
|
|
49
|
-
visible?: boolean,
|
|
50
|
-
items: ListItem[],
|
|
51
|
-
onHide?: () => void,
|
|
52
|
-
maxHeight?: string,
|
|
53
|
-
before?: ReactElement,
|
|
54
|
-
after?: ReactElement,
|
|
55
|
-
scroll?: boolean,
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
interface RenderOptions {
|
|
59
|
-
setCurrent: (current: CurrentItemList) => void,
|
|
60
|
-
controllerId?: string,
|
|
61
|
-
onClose?: () => void,
|
|
62
|
-
Link: AnchorComponent,
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const SelectionBox = styled.div<{ $maxHeight: string, $scroll?: boolean }>`
|
|
66
|
-
max-height: 0;
|
|
67
|
-
overflow-y: ${({ $scroll }) => $scroll ? 'auto' : 'hidden'};
|
|
68
|
-
overflow-x: hidden;
|
|
69
|
-
transition: ${MAX_HEIGHT_TRANSITION}, visibility 0s ${ANIMATION_DURATION_MS / 1000}s;
|
|
70
|
-
z-index: 1;
|
|
71
|
-
box-shadow: 4px 4px 48px #000;
|
|
72
|
-
border-radius: 0.5rem;
|
|
73
|
-
visibility: hidden;
|
|
74
|
-
|
|
75
|
-
.selection-list-content {
|
|
76
|
-
display: flex;
|
|
77
|
-
flex-direction: column;
|
|
78
|
-
background: ${theme.color.light['500']};
|
|
79
|
-
border-radius: 0.5rem;
|
|
80
|
-
border: 1px solid ${theme.color.light['600']};
|
|
81
|
-
background-color: ${theme.color.light['300']};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.section-title, li > a {
|
|
85
|
-
height: 40px;
|
|
86
|
-
padding: 0 8px;
|
|
87
|
-
display: flex;
|
|
88
|
-
flex-direction: row;
|
|
89
|
-
align-items: center;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
li > a {
|
|
93
|
-
gap: 4px;
|
|
94
|
-
transition: background-color 0.2s;
|
|
95
|
-
&:hover, &:focus {
|
|
96
|
-
background: ${theme.color.light['400']};
|
|
97
|
-
}
|
|
98
|
-
.label {
|
|
99
|
-
flex: 1;
|
|
100
|
-
white-space: nowrap;
|
|
101
|
-
overflow: hidden;
|
|
102
|
-
text-overflow: ellipsis;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
li.section {
|
|
107
|
-
border-bottom: 2px solid ${theme.color.light['600']};
|
|
108
|
-
&:last-child {
|
|
109
|
-
border-bottom: none;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
&.visible {
|
|
114
|
-
max-height: ${({ $maxHeight }) => $maxHeight};
|
|
115
|
-
visibility: visible;
|
|
116
|
-
transition: ${MAX_HEIGHT_TRANSITION};
|
|
117
|
-
}
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
function renderAction({
|
|
121
|
-
label, href, onClick, icon, iconRight, active, target, iconActive = <Check />,
|
|
122
|
-
}: ListAction, { onClose, Link }: RenderOptions) {
|
|
123
|
-
function handleClick() {
|
|
124
|
-
onClick?.()
|
|
125
|
-
onClose?.()
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const isTextLabel = typeof label === 'string'
|
|
129
|
-
|
|
130
|
-
return (
|
|
131
|
-
<li key={isTextLabel ? label : label.id} className="action">
|
|
132
|
-
<Link href={href} onClick={handleClick} target={target} tabIndex={0} aria-selected={active}>
|
|
133
|
-
{icon && <IconBox>{icon}</IconBox>}
|
|
134
|
-
{isTextLabel ? <Text appearance="body2" className="label">{label}</Text> : label.element}
|
|
135
|
-
{iconRight && <IconBox>{iconRight}</IconBox>}
|
|
136
|
-
{active && <IconBox>{iconActive}</IconBox>}
|
|
137
|
-
</Link>
|
|
138
|
-
</li>
|
|
139
|
-
)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function renderCollapsible({ label, icon, iconRight, children }: ListCollapsible, { setCurrent, controllerId, Link }: RenderOptions) {
|
|
143
|
-
function handleClick(ev: React.MouseEvent) {
|
|
144
|
-
// accessibility: this will tell the screen reader the section was expanded before this link is removed from the DOM.
|
|
145
|
-
(ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'true')
|
|
146
|
-
setCurrent({ items: children, label })
|
|
147
|
-
}
|
|
148
|
-
return (
|
|
149
|
-
<li key={label} className="collapsible">
|
|
150
|
-
<Link onClick={handleClick} tabIndex={0} aria-expanded={false} aria-controls={controllerId}>
|
|
151
|
-
{icon && <IconBox>{icon}</IconBox>}
|
|
152
|
-
<Text appearance="body2" className="label">{label}</Text>
|
|
153
|
-
{iconRight && <IconBox>{iconRight}</IconBox>}
|
|
154
|
-
<IconBox><ChevronRight /></IconBox>
|
|
155
|
-
</Link>
|
|
156
|
-
</li>
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function renderSection({ label, children }: ListSection, options: RenderOptions) {
|
|
161
|
-
return (
|
|
162
|
-
<li key={label ?? children.map(c => c.label).join('-')} className="section">
|
|
163
|
-
{label && <Text appearance="overheader2" colorScheme="primary" className="section-title">{label}</Text>}
|
|
164
|
-
<ul>{children.map(i => renderItem(i, options))}</ul>
|
|
165
|
-
</li>
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function renderItem(item: ListItem, options: RenderOptions) {
|
|
170
|
-
if ('children' in item) {
|
|
171
|
-
return item.type === 'section' ? renderSection(item, options) : renderCollapsible(item, options)
|
|
172
|
-
}
|
|
173
|
-
return renderAction(item, options)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export const SelectionList = ({
|
|
177
|
-
id, items, className, style, visible = true, maxHeight = '300px', onHide, before, after, scroll,
|
|
178
|
-
}: SelectionListProps) => {
|
|
179
|
-
const Link = useAnchorTag()
|
|
180
|
-
const t = useTranslate(dictionary)
|
|
181
|
-
const [current, setCurrent] = useState<CurrentItemList>({ items })
|
|
182
|
-
const { keyboardControlledElement: wrapper, attachKeyboardListeners, detachKeyboardListeners } = useKeyboardControls(
|
|
183
|
-
{ onPressEscape: onHide, querySelectors: 'li.action a, li.collapsible a, button' },
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
const listItems = useMemo(
|
|
187
|
-
() => current.items.map(i => renderItem(
|
|
188
|
-
i,
|
|
189
|
-
{
|
|
190
|
-
setCurrent: (next: CurrentItemList) => setCurrent({ ...next, parent: current }),
|
|
191
|
-
onClose: onHide,
|
|
192
|
-
controllerId: id,
|
|
193
|
-
Link,
|
|
194
|
-
},
|
|
195
|
-
)),
|
|
196
|
-
[current],
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
const hide = useCallback((event: Event) => {
|
|
200
|
-
const target = (event.target as HTMLElement | null)
|
|
201
|
-
// if the element is not in the DOM anymore, we'll consider the click was inside the selection list
|
|
202
|
-
const isClickInsideSelectionList = !target?.isConnected || wrapper.current?.contains(target)
|
|
203
|
-
const isAction = target?.classList?.contains('action') || !!target?.closest('.action')
|
|
204
|
-
if (!isClickInsideSelectionList || isAction) onHide?.()
|
|
205
|
-
}, [])
|
|
206
|
-
|
|
207
|
-
useEffect(() => {
|
|
208
|
-
if (visible) {
|
|
209
|
-
setCurrent({ items })
|
|
210
|
-
attachKeyboardListeners()
|
|
211
|
-
if (onHide) setTimeout(() => document.addEventListener('click', hide), 50)
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
detachKeyboardListeners()
|
|
215
|
-
document.removeEventListener('click', hide)
|
|
216
|
-
}
|
|
217
|
-
}, [visible])
|
|
218
|
-
|
|
219
|
-
return (
|
|
220
|
-
<SelectionBox
|
|
221
|
-
id={id}
|
|
222
|
-
ref={wrapper}
|
|
223
|
-
$maxHeight={maxHeight}
|
|
224
|
-
style={style}
|
|
225
|
-
className={listToClass(['selection-list', visible ? 'visible' : undefined, className])}
|
|
226
|
-
$scroll={scroll}
|
|
227
|
-
aria-hidden={!visible}
|
|
228
|
-
>
|
|
229
|
-
<div className="selection-list-content">
|
|
230
|
-
{before}
|
|
231
|
-
{current.parent
|
|
232
|
-
? (
|
|
233
|
-
<Flex mt={5} mb={1} alignItems="center">
|
|
234
|
-
<IconButton
|
|
235
|
-
onClick={(ev) => {
|
|
236
|
-
// accessibility: this will tell the screen reader the section was collapsed before this button is removed from the DOM.
|
|
237
|
-
(ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'false')
|
|
238
|
-
setCurrent(current.parent ?? { items })
|
|
239
|
-
}}
|
|
240
|
-
sx={{ mr: 3 }}
|
|
241
|
-
title={t.back}
|
|
242
|
-
aria-controls={id}
|
|
243
|
-
aria-expanded={true}
|
|
244
|
-
>
|
|
245
|
-
<ArrowLeft />
|
|
246
|
-
</IconButton>
|
|
247
|
-
<Text appearance="microtext1">{current.label}</Text>
|
|
248
|
-
</Flex>
|
|
249
|
-
)
|
|
250
|
-
: undefined
|
|
251
|
-
}
|
|
252
|
-
<ul>
|
|
253
|
-
{listItems}
|
|
254
|
-
{after &&
|
|
255
|
-
<li className="action">
|
|
256
|
-
{after}
|
|
257
|
-
</li>
|
|
258
|
-
}
|
|
259
|
-
</ul>
|
|
260
|
-
</div>
|
|
261
|
-
</SelectionBox>
|
|
262
|
-
)
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const dictionary = {
|
|
266
|
-
en: {
|
|
267
|
-
back: 'Go back',
|
|
268
|
-
},
|
|
269
|
-
pt: {
|
|
270
|
-
back: 'Voltar',
|
|
271
|
-
},
|
|
272
|
-
} satisfies Dictionary
|
|
1
|
+
import { Flex, IconBox, Text } from '@citric/core'
|
|
2
|
+
import { ArrowLeft, Check, ChevronRight } from '@citric/icons'
|
|
3
|
+
import { IconButton } from '@citric/ui'
|
|
4
|
+
import { WithStyle, listToClass, theme } from '@stack-spot/portal-theme'
|
|
5
|
+
import { Dictionary, useTranslate } from '@stack-spot/portal-translate'
|
|
6
|
+
import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
|
|
7
|
+
import { styled } from 'styled-components'
|
|
8
|
+
import { AnchorComponent, useAnchorTag } from '../layout-context'
|
|
9
|
+
import { useKeyboardControls } from './menu/use-keyboard-controls'
|
|
10
|
+
import { Action } from './types'
|
|
11
|
+
|
|
12
|
+
interface ItemWithIcon {
|
|
13
|
+
icon?: React.ReactElement,
|
|
14
|
+
iconRight?: React.ReactElement,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ListAction extends ItemWithIcon, Action {
|
|
18
|
+
active?: boolean,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ListGroup {
|
|
22
|
+
type?: 'section' | 'collapsible',
|
|
23
|
+
children: ListItem[],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ListSection extends ListGroup {
|
|
27
|
+
type: 'section',
|
|
28
|
+
label?: string,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ListCollapsible extends ListGroup, ItemWithIcon {
|
|
32
|
+
type?: 'collapsible',
|
|
33
|
+
label: string,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ListItem = ListSection | ListCollapsible | ListAction
|
|
37
|
+
|
|
38
|
+
interface CurrentItemList {
|
|
39
|
+
items: ListItem[],
|
|
40
|
+
label?: string,
|
|
41
|
+
parent?: CurrentItemList,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ANIMATION_DURATION_MS = 300
|
|
45
|
+
const MAX_HEIGHT_TRANSITION = `max-height ease-in ${ANIMATION_DURATION_MS / 1000}s`
|
|
46
|
+
|
|
47
|
+
export interface SelectionListProps extends WithStyle {
|
|
48
|
+
id: string,
|
|
49
|
+
visible?: boolean,
|
|
50
|
+
items: ListItem[],
|
|
51
|
+
onHide?: () => void,
|
|
52
|
+
maxHeight?: string,
|
|
53
|
+
before?: ReactElement,
|
|
54
|
+
after?: ReactElement,
|
|
55
|
+
scroll?: boolean,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RenderOptions {
|
|
59
|
+
setCurrent: (current: CurrentItemList) => void,
|
|
60
|
+
controllerId?: string,
|
|
61
|
+
onClose?: () => void,
|
|
62
|
+
Link: AnchorComponent,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SelectionBox = styled.div<{ $maxHeight: string, $scroll?: boolean }>`
|
|
66
|
+
max-height: 0;
|
|
67
|
+
overflow-y: ${({ $scroll }) => $scroll ? 'auto' : 'hidden'};
|
|
68
|
+
overflow-x: hidden;
|
|
69
|
+
transition: ${MAX_HEIGHT_TRANSITION}, visibility 0s ${ANIMATION_DURATION_MS / 1000}s;
|
|
70
|
+
z-index: 1;
|
|
71
|
+
box-shadow: 4px 4px 48px #000;
|
|
72
|
+
border-radius: 0.5rem;
|
|
73
|
+
visibility: hidden;
|
|
74
|
+
|
|
75
|
+
.selection-list-content {
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
background: ${theme.color.light['500']};
|
|
79
|
+
border-radius: 0.5rem;
|
|
80
|
+
border: 1px solid ${theme.color.light['600']};
|
|
81
|
+
background-color: ${theme.color.light['300']};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.section-title, li > a {
|
|
85
|
+
height: 40px;
|
|
86
|
+
padding: 0 8px;
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: row;
|
|
89
|
+
align-items: center;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
li > a {
|
|
93
|
+
gap: 4px;
|
|
94
|
+
transition: background-color 0.2s;
|
|
95
|
+
&:hover, &:focus {
|
|
96
|
+
background: ${theme.color.light['400']};
|
|
97
|
+
}
|
|
98
|
+
.label {
|
|
99
|
+
flex: 1;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
text-overflow: ellipsis;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
li.section {
|
|
107
|
+
border-bottom: 2px solid ${theme.color.light['600']};
|
|
108
|
+
&:last-child {
|
|
109
|
+
border-bottom: none;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
&.visible {
|
|
114
|
+
max-height: ${({ $maxHeight }) => $maxHeight};
|
|
115
|
+
visibility: visible;
|
|
116
|
+
transition: ${MAX_HEIGHT_TRANSITION};
|
|
117
|
+
}
|
|
118
|
+
`
|
|
119
|
+
|
|
120
|
+
function renderAction({
|
|
121
|
+
label, href, onClick, icon, iconRight, active, target, iconActive = <Check />,
|
|
122
|
+
}: ListAction, { onClose, Link }: RenderOptions) {
|
|
123
|
+
function handleClick() {
|
|
124
|
+
onClick?.()
|
|
125
|
+
onClose?.()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isTextLabel = typeof label === 'string'
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<li key={isTextLabel ? label : label.id} className="action">
|
|
132
|
+
<Link href={href} onClick={handleClick} target={target} tabIndex={0} aria-selected={active}>
|
|
133
|
+
{icon && <IconBox>{icon}</IconBox>}
|
|
134
|
+
{isTextLabel ? <Text appearance="body2" className="label">{label}</Text> : label.element}
|
|
135
|
+
{iconRight && <IconBox>{iconRight}</IconBox>}
|
|
136
|
+
{active && <IconBox>{iconActive}</IconBox>}
|
|
137
|
+
</Link>
|
|
138
|
+
</li>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderCollapsible({ label, icon, iconRight, children }: ListCollapsible, { setCurrent, controllerId, Link }: RenderOptions) {
|
|
143
|
+
function handleClick(ev: React.MouseEvent) {
|
|
144
|
+
// accessibility: this will tell the screen reader the section was expanded before this link is removed from the DOM.
|
|
145
|
+
(ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'true')
|
|
146
|
+
setCurrent({ items: children, label })
|
|
147
|
+
}
|
|
148
|
+
return (
|
|
149
|
+
<li key={label} className="collapsible">
|
|
150
|
+
<Link onClick={handleClick} tabIndex={0} aria-expanded={false} aria-controls={controllerId}>
|
|
151
|
+
{icon && <IconBox>{icon}</IconBox>}
|
|
152
|
+
<Text appearance="body2" className="label">{label}</Text>
|
|
153
|
+
{iconRight && <IconBox>{iconRight}</IconBox>}
|
|
154
|
+
<IconBox><ChevronRight /></IconBox>
|
|
155
|
+
</Link>
|
|
156
|
+
</li>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderSection({ label, children }: ListSection, options: RenderOptions) {
|
|
161
|
+
return (
|
|
162
|
+
<li key={label ?? children.map(c => c.label).join('-')} className="section">
|
|
163
|
+
{label && <Text appearance="overheader2" colorScheme="primary" className="section-title">{label}</Text>}
|
|
164
|
+
<ul>{children.map(i => renderItem(i, options))}</ul>
|
|
165
|
+
</li>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderItem(item: ListItem, options: RenderOptions) {
|
|
170
|
+
if ('children' in item) {
|
|
171
|
+
return item.type === 'section' ? renderSection(item, options) : renderCollapsible(item, options)
|
|
172
|
+
}
|
|
173
|
+
return renderAction(item, options)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const SelectionList = ({
|
|
177
|
+
id, items, className, style, visible = true, maxHeight = '300px', onHide, before, after, scroll,
|
|
178
|
+
}: SelectionListProps) => {
|
|
179
|
+
const Link = useAnchorTag()
|
|
180
|
+
const t = useTranslate(dictionary)
|
|
181
|
+
const [current, setCurrent] = useState<CurrentItemList>({ items })
|
|
182
|
+
const { keyboardControlledElement: wrapper, attachKeyboardListeners, detachKeyboardListeners } = useKeyboardControls(
|
|
183
|
+
{ onPressEscape: onHide, querySelectors: 'li.action a, li.collapsible a, button' },
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const listItems = useMemo(
|
|
187
|
+
() => current.items.map(i => renderItem(
|
|
188
|
+
i,
|
|
189
|
+
{
|
|
190
|
+
setCurrent: (next: CurrentItemList) => setCurrent({ ...next, parent: current }),
|
|
191
|
+
onClose: onHide,
|
|
192
|
+
controllerId: id,
|
|
193
|
+
Link,
|
|
194
|
+
},
|
|
195
|
+
)),
|
|
196
|
+
[current],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const hide = useCallback((event: Event) => {
|
|
200
|
+
const target = (event.target as HTMLElement | null)
|
|
201
|
+
// if the element is not in the DOM anymore, we'll consider the click was inside the selection list
|
|
202
|
+
const isClickInsideSelectionList = !target?.isConnected || wrapper.current?.contains(target)
|
|
203
|
+
const isAction = target?.classList?.contains('action') || !!target?.closest('.action')
|
|
204
|
+
if (!isClickInsideSelectionList || isAction) onHide?.()
|
|
205
|
+
}, [])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (visible) {
|
|
209
|
+
setCurrent({ items })
|
|
210
|
+
attachKeyboardListeners()
|
|
211
|
+
if (onHide) setTimeout(() => document.addEventListener('click', hide), 50)
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
detachKeyboardListeners()
|
|
215
|
+
document.removeEventListener('click', hide)
|
|
216
|
+
}
|
|
217
|
+
}, [visible])
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<SelectionBox
|
|
221
|
+
id={id}
|
|
222
|
+
ref={wrapper}
|
|
223
|
+
$maxHeight={maxHeight}
|
|
224
|
+
style={style}
|
|
225
|
+
className={listToClass(['selection-list', visible ? 'visible' : undefined, className])}
|
|
226
|
+
$scroll={scroll}
|
|
227
|
+
aria-hidden={!visible}
|
|
228
|
+
>
|
|
229
|
+
<div className="selection-list-content">
|
|
230
|
+
{before}
|
|
231
|
+
{current.parent
|
|
232
|
+
? (
|
|
233
|
+
<Flex mt={5} mb={1} alignItems="center">
|
|
234
|
+
<IconButton
|
|
235
|
+
onClick={(ev) => {
|
|
236
|
+
// accessibility: this will tell the screen reader the section was collapsed before this button is removed from the DOM.
|
|
237
|
+
(ev.target as HTMLElement)?.setAttribute?.('aria-expanded', 'false')
|
|
238
|
+
setCurrent(current.parent ?? { items })
|
|
239
|
+
}}
|
|
240
|
+
sx={{ mr: 3 }}
|
|
241
|
+
title={t.back}
|
|
242
|
+
aria-controls={id}
|
|
243
|
+
aria-expanded={true}
|
|
244
|
+
>
|
|
245
|
+
<ArrowLeft />
|
|
246
|
+
</IconButton>
|
|
247
|
+
<Text appearance="microtext1">{current.label}</Text>
|
|
248
|
+
</Flex>
|
|
249
|
+
)
|
|
250
|
+
: undefined
|
|
251
|
+
}
|
|
252
|
+
<ul>
|
|
253
|
+
{listItems}
|
|
254
|
+
{after &&
|
|
255
|
+
<li className="action">
|
|
256
|
+
{after}
|
|
257
|
+
</li>
|
|
258
|
+
}
|
|
259
|
+
</ul>
|
|
260
|
+
</div>
|
|
261
|
+
</SelectionBox>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const dictionary = {
|
|
266
|
+
en: {
|
|
267
|
+
back: 'Go back',
|
|
268
|
+
},
|
|
269
|
+
pt: {
|
|
270
|
+
back: 'Voltar',
|
|
271
|
+
},
|
|
272
|
+
} satisfies Dictionary
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { TimesMini } from '@citric/icons'
|
|
2
|
-
import { IconButton } from '@citric/ui'
|
|
3
|
-
import { CloseButtonProps, ToastContainer } from 'react-toastify'
|
|
4
|
-
import 'react-toastify/dist/ReactToastify.css'
|
|
5
|
-
import { useDictionary } from '../dictionary'
|
|
6
|
-
|
|
7
|
-
const CloseButton = ({ closeToast }: CloseButtonProps) => {
|
|
8
|
-
const t = useDictionary()
|
|
9
|
-
return (
|
|
10
|
-
<IconButton onClick={() => closeToast(null as any)} title={t.dismiss}>
|
|
11
|
-
<TimesMini />
|
|
12
|
-
</IconButton>
|
|
13
|
-
)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const Toaster = () => <ToastContainer closeButton={CloseButton} />
|
|
1
|
+
import { TimesMini } from '@citric/icons'
|
|
2
|
+
import { IconButton } from '@citric/ui'
|
|
3
|
+
import { CloseButtonProps, ToastContainer } from 'react-toastify'
|
|
4
|
+
import 'react-toastify/dist/ReactToastify.css'
|
|
5
|
+
import { useDictionary } from '../dictionary'
|
|
6
|
+
|
|
7
|
+
const CloseButton = ({ closeToast }: CloseButtonProps) => {
|
|
8
|
+
const t = useDictionary()
|
|
9
|
+
return (
|
|
10
|
+
<IconButton onClick={() => closeToast(null as any)} title={t.dismiss}>
|
|
11
|
+
<TimesMini />
|
|
12
|
+
</IconButton>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Toaster = () => <ToastContainer closeButton={CloseButton} />
|