@stack-spot/portal-layout 0.0.10 → 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.
- package/dist/Layout.d.ts +5 -3
- package/dist/Layout.d.ts.map +1 -1
- package/dist/Layout.js +12 -7
- package/dist/Layout.js.map +1 -1
- package/dist/LayoutOverlayManager.d.ts +7 -0
- package/dist/LayoutOverlayManager.d.ts.map +1 -1
- package/dist/LayoutOverlayManager.js +67 -23
- 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 +87 -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/error/ErrorBoundary.d.ts +1 -1
- package/dist/components/error/ErrorBoundary.d.ts.map +1 -1
- package/dist/components/error/ErrorBoundary.js +3 -2
- package/dist/components/error/ErrorBoundary.js.map +1 -1
- package/dist/components/error/ErrorFeedback.d.ts +1 -1
- package/dist/components/error/ErrorFeedback.d.ts.map +1 -1
- package/dist/components/error/ErrorManager.d.ts +16 -0
- package/dist/components/error/ErrorManager.d.ts.map +1 -0
- package/dist/components/error/ErrorManager.js +23 -0
- package/dist/components/error/ErrorManager.js.map +1 -0
- package/dist/components/error/SilentErrorBoundary.d.ts +1 -1
- package/dist/components/error/SilentErrorBoundary.d.ts.map +1 -1
- package/dist/components/error/SilentErrorBoundary.js +3 -2
- package/dist/components/error/SilentErrorBoundary.js.map +1 -1
- package/dist/components/menu/MenuContent.d.ts.map +1 -1
- package/dist/components/menu/MenuContent.js +10 -6
- 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 +13 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +13 -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 +6 -2
- package/dist/toaster.d.ts.map +1 -1
- package/dist/toaster.js.map +1 -1
- package/dist/utils.d.ts +45 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +80 -0
- package/dist/utils.js.map +1 -1
- package/package.json +4 -4
- package/src/Layout.tsx +15 -8
- package/src/LayoutOverlayManager.tsx +72 -33
- package/src/components/OverlayContent.tsx +3 -1
- package/src/components/SelectionList.tsx +111 -28
- package/src/components/UserMenu.tsx +23 -3
- package/src/components/error/ErrorBoundary.tsx +3 -2
- package/src/components/error/ErrorFeedback.tsx +1 -1
- package/src/components/error/{ErrorDescriptor.ts → ErrorManager.ts} +11 -1
- package/src/components/error/SilentErrorBoundary.tsx +3 -2
- package/src/components/menu/MenuContent.tsx +18 -7
- package/src/components/menu/MenuSections.tsx +14 -1
- package/src/components/menu/PageSelector.tsx +24 -2
- package/src/elements.ts +19 -0
- package/src/index.ts +2 -1
- package/src/layout.css +6 -2
- package/src/toaster.tsx +1 -2
- package/src/utils.ts +94 -0
package/dist/utils.js
CHANGED
|
@@ -5,4 +5,84 @@ export function valueOfLayoutVar(varname) {
|
|
|
5
5
|
return '';
|
|
6
6
|
return valueOf(varname, layout);
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Important for accessibility.
|
|
10
|
+
*
|
|
11
|
+
* Makes it so we focus the next focusable element in the DOM hierarchy, disregarding the element passed as parameter and its children.
|
|
12
|
+
*
|
|
13
|
+
* If there's no next focusable element, the first focusable of the page will be focused. If the page doesn't contain any focusable
|
|
14
|
+
* element, nothing happens.
|
|
15
|
+
*
|
|
16
|
+
* @param current the reference element to focus the next. If not provided, will be the currently active element.
|
|
17
|
+
*/
|
|
18
|
+
export function focusNextIgnoringChildren(current) {
|
|
19
|
+
current = current ?? document.activeElement;
|
|
20
|
+
while (current && !current.nextElementSibling) {
|
|
21
|
+
current = current?.parentElement;
|
|
22
|
+
}
|
|
23
|
+
current = current?.nextElementSibling;
|
|
24
|
+
while (current && current.tabIndex < 0) {
|
|
25
|
+
current = (current.children.length ? current.firstChild : current.nextElementSibling);
|
|
26
|
+
}
|
|
27
|
+
if (current)
|
|
28
|
+
current?.focus?.();
|
|
29
|
+
else
|
|
30
|
+
focusFirstChild(document);
|
|
31
|
+
}
|
|
32
|
+
const selectors = {
|
|
33
|
+
a: 'a[href]:not(:disabled)',
|
|
34
|
+
button: 'button:not(:disabled)',
|
|
35
|
+
input: 'input:not(:disabled):not([type="hidden"])',
|
|
36
|
+
select: 'textarea:not(:disabled)',
|
|
37
|
+
textarea: 'select:not(:disabled)',
|
|
38
|
+
other: '[tabindex]:not([tabindex="-1"])',
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Focus the first focusable child of the element provided. If the element has no focusable child, nothing happens.
|
|
42
|
+
*
|
|
43
|
+
* A priority list can be passed in the second parameter, as an option. If it's provided, it will focus the first element according to the
|
|
44
|
+
* list.
|
|
45
|
+
*
|
|
46
|
+
* An ignore query selector can also be passed in the options parameter. If the first focusable element matches the query selector, the
|
|
47
|
+
* next element is focused instead.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* Suppose the children of element are: h1, button, p, input, select.
|
|
51
|
+
* 1. We don't pass a priority list. The focused element will be the button.
|
|
52
|
+
* 2. Our priority list is ['button']. The focused element will be the button.
|
|
53
|
+
* 3. Our priority list is ['input', 'button']. The focused element will be the input.
|
|
54
|
+
* 4. Our priority list is ['select', 'input']. The focused element will be the select.
|
|
55
|
+
* 5. Our priority list is [['select', 'input'], 'button']. The focused element will be the input.
|
|
56
|
+
*
|
|
57
|
+
* @param element the element to search a child to focus.
|
|
58
|
+
* @param options optional.
|
|
59
|
+
*/
|
|
60
|
+
export function focusFirstChild(element, { priority = [], ignore } = {}) {
|
|
61
|
+
let focusable;
|
|
62
|
+
let missing = ['a', 'button', 'input', 'other', 'select', 'textarea'];
|
|
63
|
+
for (const p of priority) {
|
|
64
|
+
const tags = Array.isArray(p) ? p : [p];
|
|
65
|
+
const querySelectors = tags.map(t => {
|
|
66
|
+
missing = missing.filter(tag => tag != t);
|
|
67
|
+
return selectors[t];
|
|
68
|
+
});
|
|
69
|
+
focusable = element?.querySelectorAll(querySelectors.join(', '));
|
|
70
|
+
if (focusable)
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (!focusable) {
|
|
74
|
+
element?.querySelectorAll(missing.map(t => selectors[t]).join(', '));
|
|
75
|
+
}
|
|
76
|
+
let elementToFocus;
|
|
77
|
+
for (const f of focusable ?? []) {
|
|
78
|
+
if (!ignore || !f.matches(ignore)) {
|
|
79
|
+
const styles = window.getComputedStyle(f);
|
|
80
|
+
if (styles.display != 'none' && styles.visibility != 'hidden') {
|
|
81
|
+
elementToFocus = f;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
elementToFocus?.focus?.();
|
|
87
|
+
}
|
|
8
88
|
//# sourceMappingURL=utils.js.map
|
package/dist/utils.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,0BAA0B,CAAA;AAElD,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC9C,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAA;IAChD,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,CAAA;IACtB,OAAO,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;AACjC,CAAC"}
|
|
1
|
+
{"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stack-spot/portal-layout",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"peerDependencies": {
|
|
8
|
-
"@citric/core": ">=5.
|
|
9
|
-
"@citric/icons": ">=5.
|
|
10
|
-
"@citric/ui": ">=5.
|
|
8
|
+
"@citric/core": ">=5.4.0",
|
|
9
|
+
"@citric/icons": ">=5.4.0",
|
|
10
|
+
"@citric/ui": ">=5.4.0",
|
|
11
11
|
"@stack-spot/portal-theme": ">=0.0.4",
|
|
12
12
|
"@stack-spot/portal-translate": ">=0.0.5",
|
|
13
13
|
"react": ">=18.2.0",
|
package/src/Layout.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { CSSToCitricAdapter, listToClass, WithStyle } from '@stack-spot/portal-theme'
|
|
2
2
|
import '@stack-spot/portal-theme/dist/theme.css'
|
|
3
|
-
import { ReactElement, ReactNode } from 'react'
|
|
3
|
+
import { ReactElement, ReactNode, useEffect } from 'react'
|
|
4
4
|
import { ErrorBoundary } from './components/error/ErrorBoundary'
|
|
5
|
-
import { DescriptionFn,
|
|
5
|
+
import { DescriptionFn, ErrorHandler, ErrorManager } from './components/error/ErrorManager'
|
|
6
6
|
import { SilentErrorBoundary } from './components/error/SilentErrorBoundary'
|
|
7
7
|
import { Header, HeaderProps } from './components/Header'
|
|
8
8
|
import { MenuContent } from './components/menu/MenuContent'
|
|
@@ -18,6 +18,7 @@ interface Props extends WithStyle {
|
|
|
18
18
|
children: ReactNode,
|
|
19
19
|
extra?: ReactNode,
|
|
20
20
|
errorDescriptor?: DescriptionFn,
|
|
21
|
+
onError?: ErrorHandler,
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
interface RawProps extends WithStyle {
|
|
@@ -28,10 +29,11 @@ interface RawProps extends WithStyle {
|
|
|
28
29
|
compactMenu?: boolean,
|
|
29
30
|
extra?: ReactNode,
|
|
30
31
|
errorDescriptor?: DescriptionFn,
|
|
32
|
+
onError?: ErrorHandler,
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export const RawLayout = (
|
|
34
|
-
{ menuSections, menuContent, header, compactMenu = true, children, extra, errorDescriptor, className, style }: RawProps,
|
|
36
|
+
{ menuSections, menuContent, header, compactMenu = true, children, extra, errorDescriptor, onError, className, style }: RawProps,
|
|
35
37
|
) => {
|
|
36
38
|
// @ts-ignore
|
|
37
39
|
const { bottomDialog, modal, rightPanel } = overlay.useOverlays()
|
|
@@ -41,7 +43,11 @@ export const RawLayout = (
|
|
|
41
43
|
menuSections ? undefined : 'no-menu-sections',
|
|
42
44
|
className,
|
|
43
45
|
]
|
|
44
|
-
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (errorDescriptor) ErrorManager.setDescriptionFunction(errorDescriptor)
|
|
49
|
+
if (onError) ErrorManager.setErrorHandler(onError)
|
|
50
|
+
}, [])
|
|
45
51
|
|
|
46
52
|
return (
|
|
47
53
|
<CSSToCitricAdapter>
|
|
@@ -55,10 +61,10 @@ export const RawLayout = (
|
|
|
55
61
|
<nav id="menuContent"><SilentErrorBoundary>{menuContent}</SilentErrorBoundary></nav>
|
|
56
62
|
{menuSections && <nav id="menuSections"><SilentErrorBoundary>{menuSections}</SilentErrorBoundary></nav>}
|
|
57
63
|
</aside>
|
|
58
|
-
<div id="
|
|
59
|
-
<div id="bottomDialog"><ErrorBoundary>{bottomDialog}</ErrorBoundary></div>
|
|
64
|
+
<div id="bottomDialog" role="dialog"><ErrorBoundary>{bottomDialog}</ErrorBoundary></div>
|
|
60
65
|
<div id="backdrop">
|
|
61
|
-
<div id="modal"><ErrorBoundary>{
|
|
66
|
+
<div id="rightPanel" aria-modal role="dialog"><ErrorBoundary>{rightPanel}</ErrorBoundary></div>
|
|
67
|
+
<div id="modal" aria-modal role="dialog"><ErrorBoundary>{modal}</ErrorBoundary></div>
|
|
62
68
|
</div>
|
|
63
69
|
<Toaster />
|
|
64
70
|
</div>
|
|
@@ -71,7 +77,7 @@ const MenuContentRenderer = ({ content }: Required<Pick<Props['menu'], 'content'
|
|
|
71
77
|
return <MenuContent {...menuContent} />
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
export const Layout = ({ menu, header, children, extra, errorDescriptor, className, style }: Props) => (
|
|
80
|
+
export const Layout = ({ menu, header, children, extra, errorDescriptor, onError, className, style }: Props) => (
|
|
75
81
|
<RawLayout
|
|
76
82
|
header={<Header {...header} />}
|
|
77
83
|
menuSections={menu.sections ? <MenuSections {...menu} /> : undefined}
|
|
@@ -81,6 +87,7 @@ export const Layout = ({ menu, header, children, extra, errorDescriptor, classNa
|
|
|
81
87
|
}
|
|
82
88
|
compactMenu={menu.compact}
|
|
83
89
|
errorDescriptor={errorDescriptor}
|
|
90
|
+
onError={onError}
|
|
84
91
|
extra={extra}
|
|
85
92
|
className={className}
|
|
86
93
|
style={style}
|
|
@@ -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,17 +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
|
-
if (this.
|
|
65
|
-
|
|
66
|
-
}
|
|
47
|
+
if (this.isModalOpen() && !this.elements?.modal?.contains?.(event.target as Node)) this.closeModal()
|
|
48
|
+
if (this.isRightPanelOpen() && !this.elements?.rightPanel?.contains?.(event.target as Node)) this.closeRightPanel()
|
|
67
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)
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
// this should actually be like Kotlin's "internal", i.e. private to any user of the lib, but public to the lib itself.
|
|
@@ -82,13 +73,57 @@ class LayoutOverlayManager {
|
|
|
82
73
|
return { modal, rightPanel, bottomDialog }
|
|
83
74
|
}
|
|
84
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
|
+
|
|
108
|
+
isModalOpen() {
|
|
109
|
+
return this.elements?.modal?.classList.contains('visible') ?? false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
isRightPanelOpen() {
|
|
113
|
+
return this.elements?.rightPanel?.classList.contains('visible') ?? false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
isBottomDialogOpen() {
|
|
117
|
+
return this.elements?.bottomDialog?.classList.contains('visible') ?? false
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
showCustomModal(content: React.ReactElement, { size = 'medium', onClose }: CustomModalOptions = {}) {
|
|
86
|
-
if (!this.elements?.modal) throw new ElementNotFound('modal',
|
|
121
|
+
if (!this.elements?.modal) throw new ElementNotFound('modal', elementIds.modal)
|
|
87
122
|
if (!this.setContent.modal) throw new LayoutError('unable to show modal, because it has not been setup yet.')
|
|
88
123
|
this.onModalClose = onClose
|
|
89
124
|
this.setContent.modal(content)
|
|
90
125
|
this.elements.backdrop?.setAttribute('class', 'visible')
|
|
91
|
-
this.elements.modal
|
|
126
|
+
this.showOverlay(this.elements.modal, [size])
|
|
92
127
|
}
|
|
93
128
|
|
|
94
129
|
showModal({ size, ...props }: OverlayContentProps & { size?: ModalSize }) {
|
|
@@ -127,7 +162,7 @@ class LayoutOverlayManager {
|
|
|
127
162
|
}
|
|
128
163
|
|
|
129
164
|
showBottomDialog({ message, cancel, confirm }: BottomDialogOptions): Promise<boolean> {
|
|
130
|
-
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog',
|
|
165
|
+
if (!this.elements?.bottomDialog) throw new ElementNotFound('bottom dialog', elementIds.bottomDialog)
|
|
131
166
|
if (!this.setContent.bottomDialog) throw new LayoutError('unable to show bottom dialog, because it has not been setup yet.')
|
|
132
167
|
return new Promise((resolve) => {
|
|
133
168
|
this.setContent.bottomDialog?.(
|
|
@@ -139,17 +174,20 @@ class LayoutOverlayManager {
|
|
|
139
174
|
</div>
|
|
140
175
|
</>,
|
|
141
176
|
)
|
|
142
|
-
this.elements?.bottomDialog
|
|
177
|
+
this.showOverlay(this.elements?.bottomDialog)
|
|
143
178
|
})
|
|
144
179
|
}
|
|
145
180
|
|
|
146
181
|
showCustomRightPanel(content: ReactElement, { size = 'medium', onClose }: CustomRightPanelOptions = {}) {
|
|
147
|
-
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay',
|
|
182
|
+
if (!this.elements?.rightPanel) throw new ElementNotFound('right panel overlay', elementIds.rightPanel)
|
|
148
183
|
if (!this.setContent.rightPanel) throw new LayoutError('unable to show right panel overlay, because it has not been setup yet.')
|
|
149
184
|
this.onModalClose = onClose
|
|
150
185
|
this.setContent.rightPanel(content)
|
|
151
186
|
this.elements?.rightPanel.classList.add(size)
|
|
152
|
-
setTimeout(() =>
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
this.elements?.backdrop?.setAttribute('class', 'visible')
|
|
189
|
+
this.showOverlay(this.elements?.rightPanel)
|
|
190
|
+
})
|
|
153
191
|
}
|
|
154
192
|
|
|
155
193
|
showRightPanel({ size, ...props }: OverlayContentProps & { size?: OverlaySize }) {
|
|
@@ -169,7 +207,7 @@ class LayoutOverlayManager {
|
|
|
169
207
|
setTimeout(
|
|
170
208
|
() => {
|
|
171
209
|
if (this.setContent.modal) this.setContent.modal(undefined)
|
|
172
|
-
this.elements?.modal
|
|
210
|
+
this.hideOverlay(this.elements?.modal)
|
|
173
211
|
},
|
|
174
212
|
parseFloat(valueOfLayoutVar('--modal-animation-duration')) * 1000,
|
|
175
213
|
)
|
|
@@ -177,6 +215,7 @@ class LayoutOverlayManager {
|
|
|
177
215
|
|
|
178
216
|
closeRightPanel(runCloseListener = true) {
|
|
179
217
|
this.elements?.rightPanel?.classList.remove('visible')
|
|
218
|
+
this.elements?.backdrop?.setAttribute('class', '')
|
|
180
219
|
if (runCloseListener && this.onModalClose) {
|
|
181
220
|
this.onModalClose()
|
|
182
221
|
this.onModalClose = undefined
|
|
@@ -184,14 +223,14 @@ class LayoutOverlayManager {
|
|
|
184
223
|
setTimeout(
|
|
185
224
|
() => {
|
|
186
225
|
if (this.setContent.rightPanel) this.setContent.rightPanel(undefined)
|
|
187
|
-
this.elements?.rightPanel
|
|
226
|
+
this.hideOverlay(this.elements?.rightPanel)
|
|
188
227
|
},
|
|
189
228
|
parseFloat(valueOfLayoutVar('--right-panel-animation-duration')) * 1000,
|
|
190
229
|
)
|
|
191
230
|
}
|
|
192
231
|
|
|
193
232
|
closeBottomDialog() {
|
|
194
|
-
this.elements?.bottomDialog
|
|
233
|
+
this.hideOverlay(this.elements?.bottomDialog)
|
|
195
234
|
}
|
|
196
235
|
|
|
197
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}>
|
|
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,120 @@ 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() {
|
|
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 (
|
|
172
|
-
|
|
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
|
|
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
|