@sanity/ui 1.1.0 → 1.2.1
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/index.cjs.mjs +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.esm.js +305 -177
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +305 -176
- package/dist/index.js.map +1 -1
- package/package.json +15 -15
- package/src/components/dialog/__workshop__/activate.tsx +134 -0
- package/src/components/dialog/__workshop__/index.ts +3 -0
- package/src/components/dialog/__workshop__/nested.tsx +14 -3
- package/src/components/dialog/__workshop__/panes.tsx +86 -0
- package/src/components/dialog/__workshop__/wrapped.tsx +78 -0
- package/src/components/dialog/dialog.tsx +110 -25
- package/src/helpers/element.ts +7 -0
- package/src/utils/layer/__workshop__/_debug.tsx +9 -3
- package/src/utils/layer/__workshop__/index.ts +3 -3
- package/src/utils/layer/__workshop__/multipleRoots.tsx +2 -1
- package/src/utils/layer/__workshop__/{plain.tsx → nested.tsx} +14 -4
- package/src/utils/layer/getLayerContext.test.ts +30 -0
- package/src/utils/layer/getLayerContext.ts +21 -0
- package/src/utils/layer/layer.test.tsx +1 -0
- package/src/utils/layer/layer.tsx +49 -7
- package/src/utils/layer/layerContext.ts +2 -2
- package/src/utils/layer/layerProvider.tsx +73 -12
- package/src/utils/layer/types.ts +3 -4
- package/src/utils/layer/useLayer.ts +9 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/ui",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"source": "./src/index.ts",
|
|
@@ -49,11 +49,11 @@
|
|
|
49
49
|
"workshop:start": "http-server -a localhost -c-0 -p 1337 -s -P http://localhost:1337/index.html? dist"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@floating-ui/react-dom": "1.
|
|
52
|
+
"@floating-ui/react-dom": "1.2.2",
|
|
53
53
|
"@sanity/color": "^2.2.2",
|
|
54
54
|
"@sanity/icons": "^2.2.2",
|
|
55
55
|
"csstype": "^3.1.1",
|
|
56
|
-
"framer-motion": "^
|
|
56
|
+
"framer-motion": "^9.0.0",
|
|
57
57
|
"react-refractor": "^2.1.7"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@testing-library/dom": "^8.20.0",
|
|
71
71
|
"@testing-library/jest-dom": "^5.16.5",
|
|
72
72
|
"@testing-library/react": "^13.4.0",
|
|
73
|
-
"@types/jest": "^29.
|
|
73
|
+
"@types/jest": "^29.4.0",
|
|
74
74
|
"@types/jest-axe": "^3.5.5",
|
|
75
75
|
"@types/node": "^18.11.18",
|
|
76
76
|
"@types/react": "^18.0.27",
|
|
@@ -79,26 +79,26 @@
|
|
|
79
79
|
"@types/refractor": "^3.0.2",
|
|
80
80
|
"@types/styled-components": "^5.1.26",
|
|
81
81
|
"@types/testing-library__jest-dom": "^5.14.5",
|
|
82
|
-
"@typescript-eslint/eslint-plugin": "^5.
|
|
83
|
-
"@typescript-eslint/parser": "^5.
|
|
82
|
+
"@typescript-eslint/eslint-plugin": "^5.50.0",
|
|
83
|
+
"@typescript-eslint/parser": "^5.50.0",
|
|
84
84
|
"commitizen": "^4.3.0",
|
|
85
|
-
"cypress": "^12.
|
|
85
|
+
"cypress": "^12.5.0",
|
|
86
86
|
"cypress-real-events": "^1.7.6",
|
|
87
87
|
"cz-conventional-changelog": "^3.3.0",
|
|
88
|
-
"esbuild": "^0.17.
|
|
88
|
+
"esbuild": "^0.17.5",
|
|
89
89
|
"esbuild-register": "^3.4.2",
|
|
90
|
-
"eslint": "^8.
|
|
90
|
+
"eslint": "^8.33.0",
|
|
91
91
|
"eslint-config-prettier": "^8.6.0",
|
|
92
92
|
"eslint-plugin-import": "^2.27.5",
|
|
93
93
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
|
94
94
|
"eslint-plugin-prettier": "^4.2.1",
|
|
95
|
-
"eslint-plugin-react": "^7.32.
|
|
95
|
+
"eslint-plugin-react": "^7.32.2",
|
|
96
96
|
"eslint-plugin-react-hooks": "^4.6.0",
|
|
97
97
|
"http-server": "^14.1.1",
|
|
98
98
|
"husky": "^8.0.3",
|
|
99
|
-
"jest": "^29.4.
|
|
99
|
+
"jest": "^29.4.1",
|
|
100
100
|
"jest-axe": "^7.0.0",
|
|
101
|
-
"jest-environment-jsdom": "^29.4.
|
|
101
|
+
"jest-environment-jsdom": "^29.4.1",
|
|
102
102
|
"lint-staged": "^13.1.0",
|
|
103
103
|
"module-alias": "^2.2.2",
|
|
104
104
|
"npm-run-all": "^4.1.5",
|
|
@@ -107,11 +107,11 @@
|
|
|
107
107
|
"react-dom": "^18.2.0",
|
|
108
108
|
"react-is": "^18.2.0",
|
|
109
109
|
"rimraf": "^4.1.2",
|
|
110
|
-
"semantic-release": "^20.0
|
|
110
|
+
"semantic-release": "^20.1.0",
|
|
111
111
|
"start-server-and-test": "^1.15.3",
|
|
112
112
|
"styled-components": "^5.3.6",
|
|
113
113
|
"tsconfig-paths": "^4.1.2",
|
|
114
|
-
"typescript": "^4.9.
|
|
114
|
+
"typescript": "^4.9.5"
|
|
115
115
|
},
|
|
116
116
|
"peerDependencies": {
|
|
117
117
|
"react": "^18",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"prettier --write --cache --ignore-unknown"
|
|
147
147
|
]
|
|
148
148
|
},
|
|
149
|
-
"packageManager": "pnpm@7.26.
|
|
149
|
+
"packageManager": "pnpm@7.26.3",
|
|
150
150
|
"publishConfig": {
|
|
151
151
|
"access": "public"
|
|
152
152
|
},
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
2
|
+
import {Stack, Flex, Button, Text} from '../../../primitives'
|
|
3
|
+
import {Layer, LayerProvider} from '../../../utils'
|
|
4
|
+
import {Menu, MenuButton, MenuItem} from '../../menu'
|
|
5
|
+
import {Dialog} from '../dialog'
|
|
6
|
+
|
|
7
|
+
export default function LayeringFocusStory() {
|
|
8
|
+
const [firstDialogOpen, setFirstDialogOpen] = useState<boolean>(false)
|
|
9
|
+
const [secondDialogOpen, setSecondDialogOpen] = useState<boolean>(false)
|
|
10
|
+
const [thirdDialogOpen, setThirdDialogOpen] = useState<boolean>(false)
|
|
11
|
+
const [fourthDialogOpen, setFourthDialogOpen] = useState<boolean>(false)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<LayerProvider>
|
|
15
|
+
<Flex align="center" height="fill" justify="center">
|
|
16
|
+
<Layer onActivate={({activeElement}) => activeElement?.focus()}>
|
|
17
|
+
<Button
|
|
18
|
+
text="Open dialog 1"
|
|
19
|
+
onClick={() => setFirstDialogOpen(true)}
|
|
20
|
+
tone="primary"
|
|
21
|
+
id="open-dialog-1-button"
|
|
22
|
+
/>
|
|
23
|
+
|
|
24
|
+
{firstDialogOpen && (
|
|
25
|
+
<Dialog
|
|
26
|
+
header="Dialog 1"
|
|
27
|
+
id="1"
|
|
28
|
+
onActivate={({activeElement}) => activeElement?.focus()}
|
|
29
|
+
onClose={() => setFirstDialogOpen(false)}
|
|
30
|
+
>
|
|
31
|
+
<Stack space={2} padding={3}>
|
|
32
|
+
<Button
|
|
33
|
+
mode="ghost"
|
|
34
|
+
text="Open dialog 2"
|
|
35
|
+
onClick={() => setSecondDialogOpen(true)}
|
|
36
|
+
id="open-dialog-2-button-1"
|
|
37
|
+
/>
|
|
38
|
+
<Button
|
|
39
|
+
mode="ghost"
|
|
40
|
+
text="Open dialog 2"
|
|
41
|
+
onClick={() => setSecondDialogOpen(true)}
|
|
42
|
+
id="open-dialog-2-button-2"
|
|
43
|
+
/>
|
|
44
|
+
<Button
|
|
45
|
+
mode="ghost"
|
|
46
|
+
text="Open dialog 2"
|
|
47
|
+
onClick={() => setSecondDialogOpen(true)}
|
|
48
|
+
id="open-dialog-2-button-3"
|
|
49
|
+
/>
|
|
50
|
+
</Stack>
|
|
51
|
+
|
|
52
|
+
{secondDialogOpen && (
|
|
53
|
+
<Dialog
|
|
54
|
+
header="Dialog 2"
|
|
55
|
+
id="2"
|
|
56
|
+
onActivate={({activeElement}) => activeElement?.focus()}
|
|
57
|
+
onClose={() => setSecondDialogOpen(false)}
|
|
58
|
+
>
|
|
59
|
+
<Stack space={2} padding={3}>
|
|
60
|
+
<Button
|
|
61
|
+
mode="ghost"
|
|
62
|
+
text="Open dialog 3"
|
|
63
|
+
onClick={() => setThirdDialogOpen(true)}
|
|
64
|
+
id="open-dialog-3-button-1"
|
|
65
|
+
/>
|
|
66
|
+
<Button
|
|
67
|
+
mode="ghost"
|
|
68
|
+
text="Open dialog 3"
|
|
69
|
+
onClick={() => setThirdDialogOpen(true)}
|
|
70
|
+
id="open-dialog-3-button-2"
|
|
71
|
+
/>
|
|
72
|
+
<Button
|
|
73
|
+
mode="ghost"
|
|
74
|
+
text="Open dialog 3"
|
|
75
|
+
onClick={() => setThirdDialogOpen(true)}
|
|
76
|
+
id="open-dialog-3-button-3"
|
|
77
|
+
/>
|
|
78
|
+
</Stack>
|
|
79
|
+
|
|
80
|
+
{thirdDialogOpen && (
|
|
81
|
+
<Dialog
|
|
82
|
+
header="Dialog 3"
|
|
83
|
+
id="3"
|
|
84
|
+
onActivate={({activeElement}) => activeElement?.focus()}
|
|
85
|
+
onClose={() => setThirdDialogOpen(false)}
|
|
86
|
+
>
|
|
87
|
+
<Stack space={2} padding={3}>
|
|
88
|
+
<MenuButton
|
|
89
|
+
id="menu"
|
|
90
|
+
button={
|
|
91
|
+
<Button mode="ghost" text="Open menu" id="open-dialog-4-menu-button" />
|
|
92
|
+
}
|
|
93
|
+
menu={
|
|
94
|
+
<Menu>
|
|
95
|
+
<MenuItem
|
|
96
|
+
text="Open dialog 4"
|
|
97
|
+
onClick={() => setFourthDialogOpen(true)}
|
|
98
|
+
/>
|
|
99
|
+
<MenuItem
|
|
100
|
+
text="Open dialog 4"
|
|
101
|
+
onClick={() => setFourthDialogOpen(true)}
|
|
102
|
+
/>
|
|
103
|
+
<MenuItem
|
|
104
|
+
text="Open dialog 4"
|
|
105
|
+
onClick={() => setFourthDialogOpen(true)}
|
|
106
|
+
/>
|
|
107
|
+
</Menu>
|
|
108
|
+
}
|
|
109
|
+
/>
|
|
110
|
+
</Stack>
|
|
111
|
+
|
|
112
|
+
{fourthDialogOpen && (
|
|
113
|
+
<Dialog
|
|
114
|
+
header="Dialog 4"
|
|
115
|
+
id="4"
|
|
116
|
+
onActivate={({activeElement}) => activeElement?.focus()}
|
|
117
|
+
onClose={() => setFourthDialogOpen(false)}
|
|
118
|
+
>
|
|
119
|
+
<Stack space={2} padding={3}>
|
|
120
|
+
<Text>👋</Text>
|
|
121
|
+
</Stack>
|
|
122
|
+
</Dialog>
|
|
123
|
+
)}
|
|
124
|
+
</Dialog>
|
|
125
|
+
)}
|
|
126
|
+
</Dialog>
|
|
127
|
+
)}
|
|
128
|
+
</Dialog>
|
|
129
|
+
)}
|
|
130
|
+
</Layer>
|
|
131
|
+
</Flex>
|
|
132
|
+
</LayerProvider>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -12,5 +12,8 @@ export default defineScope({
|
|
|
12
12
|
{name: 'position', title: 'Position', component: lazy(() => import('./position'))},
|
|
13
13
|
{name: 'provider', title: 'Provider', component: lazy(() => import('./provider'))},
|
|
14
14
|
{name: 'auto-focus', title: 'AutoFocus', component: lazy(() => import('./autoFocus'))},
|
|
15
|
+
{name: 'panes', title: 'Panes', component: lazy(() => import('./panes'))},
|
|
16
|
+
{name: 'activate', title: 'Activate', component: lazy(() => import('./activate'))},
|
|
17
|
+
{name: 'wrapped', title: 'Wrapped', component: lazy(() => import('./wrapped'))},
|
|
15
18
|
],
|
|
16
19
|
})
|
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
import {Box, Button, Dialog, LayerProvider, Menu, MenuButton, MenuItem} from '@sanity/ui'
|
|
2
|
-
import {useState} from 'react'
|
|
2
|
+
import {useEffect, useRef, useState} from 'react'
|
|
3
3
|
|
|
4
4
|
export default function NestedStory() {
|
|
5
5
|
const [open1] = useState(true)
|
|
6
6
|
const [open2, setOpen2] = useState(false)
|
|
7
7
|
const [open3, setOpen3] = useState(false)
|
|
8
8
|
|
|
9
|
+
const dialog2Button = useRef<HTMLButtonElement>(null)
|
|
10
|
+
const dialog3Button = useRef<HTMLButtonElement>(null)
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!open2) dialog2Button.current?.focus()
|
|
14
|
+
}, [open2])
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!open3) dialog3Button.current?.focus()
|
|
18
|
+
}, [open3])
|
|
19
|
+
|
|
9
20
|
return (
|
|
10
21
|
<LayerProvider>
|
|
11
22
|
{open1 && (
|
|
12
23
|
<Dialog cardShadow={1} header="Dialog 1" id="dialog1">
|
|
13
24
|
<Box padding={4}>
|
|
14
|
-
<Button onClick={() => setOpen2(true)} text="Open Dialog 2" />
|
|
25
|
+
<Button onClick={() => setOpen2(true)} ref={dialog2Button} text="Open Dialog 2" />
|
|
15
26
|
</Box>
|
|
16
27
|
|
|
17
28
|
{open2 && (
|
|
18
29
|
<Dialog cardShadow={2} header="Dialog 2" id="dialog2" onClose={() => setOpen2(false)}>
|
|
19
30
|
<Box padding={4}>
|
|
20
|
-
<Button onClick={() => setOpen3(true)} text="Open Dialog 3" />
|
|
31
|
+
<Button onClick={() => setOpen3(true)} ref={dialog3Button} text="Open Dialog 3" />
|
|
21
32
|
</Box>
|
|
22
33
|
|
|
23
34
|
{open3 && (
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {useCallback, useState} from 'react'
|
|
2
|
+
import styled from 'styled-components'
|
|
3
|
+
import {Box, Button, Card, Flex} from '../../../primitives'
|
|
4
|
+
import {BoundaryElementProvider, PortalProvider} from '../../../utils'
|
|
5
|
+
import {Menu, MenuButton, MenuItem} from '../../menu'
|
|
6
|
+
import {Dialog} from '../dialog'
|
|
7
|
+
|
|
8
|
+
export default function PanesStory() {
|
|
9
|
+
return (
|
|
10
|
+
<Flex height="fill">
|
|
11
|
+
<Pane id="A" />
|
|
12
|
+
<Pane id="B" borderLeft />
|
|
13
|
+
</Flex>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PaneRoot = styled(Card)`
|
|
18
|
+
position: relative;
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
const PanePortal = styled.div`
|
|
22
|
+
position: absolute;
|
|
23
|
+
left: 0;
|
|
24
|
+
right: 0;
|
|
25
|
+
top: 0;
|
|
26
|
+
bottom: 0;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
|
|
29
|
+
& > * {
|
|
30
|
+
pointer-events: auto;
|
|
31
|
+
}
|
|
32
|
+
`
|
|
33
|
+
|
|
34
|
+
function Pane(props: {borderLeft?: boolean; id: string}) {
|
|
35
|
+
const {borderLeft, id} = props
|
|
36
|
+
const [element, setElement] = useState<HTMLDivElement | null>(null)
|
|
37
|
+
const [portalElement, setPortalElement] = useState<HTMLDivElement | null>(null)
|
|
38
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
39
|
+
|
|
40
|
+
const handleClose = useCallback(() => {
|
|
41
|
+
setDialogOpen(false)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<BoundaryElementProvider element={element}>
|
|
46
|
+
<PortalProvider element={portalElement}>
|
|
47
|
+
<PaneRoot borderLeft={borderLeft} flex={1} ref={setElement}>
|
|
48
|
+
<Box padding={4}>
|
|
49
|
+
<Button
|
|
50
|
+
onClick={() => setDialogOpen(true)}
|
|
51
|
+
selected={dialogOpen}
|
|
52
|
+
text="Open dialog"
|
|
53
|
+
tone="primary"
|
|
54
|
+
/>
|
|
55
|
+
</Box>
|
|
56
|
+
|
|
57
|
+
<PanePortal ref={setPortalElement} />
|
|
58
|
+
</PaneRoot>
|
|
59
|
+
|
|
60
|
+
{dialogOpen && (
|
|
61
|
+
<Dialog
|
|
62
|
+
header={`Dialog ${id}`}
|
|
63
|
+
id={id}
|
|
64
|
+
onClickOutside={handleClose}
|
|
65
|
+
onClose={handleClose}
|
|
66
|
+
position="absolute"
|
|
67
|
+
>
|
|
68
|
+
<Box padding={4}>
|
|
69
|
+
<MenuButton
|
|
70
|
+
button={<Button text="Open menu" tone="primary" />}
|
|
71
|
+
id={`${id}-menu`}
|
|
72
|
+
menu={
|
|
73
|
+
<Menu>
|
|
74
|
+
<MenuItem text="Item 1" />
|
|
75
|
+
<MenuItem text="Item 2" />
|
|
76
|
+
<MenuItem text="Item 3" />
|
|
77
|
+
</Menu>
|
|
78
|
+
}
|
|
79
|
+
/>
|
|
80
|
+
</Box>
|
|
81
|
+
</Dialog>
|
|
82
|
+
)}
|
|
83
|
+
</PortalProvider>
|
|
84
|
+
</BoundaryElementProvider>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {ReactNode, useCallback, useEffect, useRef, useState} from 'react'
|
|
2
|
+
import {Box, Button} from '../../../primitives'
|
|
3
|
+
import {LayerProvider, useLayer} from '../../../utils'
|
|
4
|
+
import {Dialog, DialogProps} from '../dialog'
|
|
5
|
+
|
|
6
|
+
export default function WrappedStory() {
|
|
7
|
+
return (
|
|
8
|
+
<LayerProvider zOffset={100}>
|
|
9
|
+
<Box padding={4}>
|
|
10
|
+
<DialogButton level={0} />
|
|
11
|
+
</Box>
|
|
12
|
+
</LayerProvider>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function DialogButton(props: {level: number}) {
|
|
17
|
+
const {level} = props
|
|
18
|
+
const [open, setOpen] = useState(false)
|
|
19
|
+
|
|
20
|
+
const openRef = useRef(open)
|
|
21
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (openRef.current !== open) {
|
|
25
|
+
openRef.current = open
|
|
26
|
+
|
|
27
|
+
if (!open) {
|
|
28
|
+
// focus the button that opened the dialog
|
|
29
|
+
// buttonRef.current?.focus()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}, [open])
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<Button onClick={() => setOpen(true)} ref={buttonRef} text={`Open dialog ${level + 1}`} />
|
|
37
|
+
|
|
38
|
+
{open && (
|
|
39
|
+
<WrappedDialog
|
|
40
|
+
header={`WrappedDialog ${level + 1}`}
|
|
41
|
+
id={`wrapped-${level + 1}`}
|
|
42
|
+
onClickOutside={() => setOpen(false)}
|
|
43
|
+
onClose={() => setOpen(false)}
|
|
44
|
+
>
|
|
45
|
+
<Box padding={4}>
|
|
46
|
+
<DialogButton level={level + 1} />
|
|
47
|
+
</Box>
|
|
48
|
+
</WrappedDialog>
|
|
49
|
+
)}
|
|
50
|
+
</>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function WrappedDialog(props: DialogProps & {children?: ReactNode}) {
|
|
55
|
+
const layer = useLayer()
|
|
56
|
+
const isTopLayer = layer.size === 1
|
|
57
|
+
|
|
58
|
+
const dialogRef = useRef<HTMLDivElement>(null)
|
|
59
|
+
|
|
60
|
+
const [lastFocusedElement, setLastFocusedElement] = useState<HTMLElement | null>(null)
|
|
61
|
+
|
|
62
|
+
const handleContentFocus = useCallback(() => {
|
|
63
|
+
const containsActiveElement = dialogRef.current?.contains(document.activeElement)
|
|
64
|
+
|
|
65
|
+
if (containsActiveElement) {
|
|
66
|
+
setLastFocusedElement(document.activeElement as HTMLElement)
|
|
67
|
+
}
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
// Set focus on the last focused element when the dialog becomes the top layer.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (isTopLayer && lastFocusedElement) {
|
|
73
|
+
lastFocusedElement.focus()
|
|
74
|
+
}
|
|
75
|
+
}, [isTopLayer, lastFocusedElement])
|
|
76
|
+
|
|
77
|
+
return <Dialog {...props} data-size={layer.size} onFocus={handleContentFocus} ref={dialogRef} />
|
|
78
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import {CloseIcon} from '@sanity/icons'
|
|
2
2
|
import {forwardRef, useCallback, useEffect, useRef, useState} from 'react'
|
|
3
3
|
import styled from 'styled-components'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
containsOrEqualsElement,
|
|
6
|
+
focusFirstDescendant,
|
|
7
|
+
focusLastDescendant,
|
|
8
|
+
isHTMLElement,
|
|
9
|
+
} from '../../helpers'
|
|
5
10
|
import {useArrayProp, useClickOutside, useForwardedRef, useGlobalKeyDown} from '../../hooks'
|
|
6
11
|
import {Box, Button, Card, Container, Flex, Text} from '../../primitives'
|
|
7
12
|
import {ResponsivePaddingProps, ResponsiveWidthProps} from '../../primitives/types'
|
|
8
13
|
import {responsivePaddingStyle, ResponsivePaddingStyleProps} from '../../styles/internal'
|
|
9
14
|
import {ThemeColorSchemeKey, useTheme} from '../../theme'
|
|
10
15
|
import {DialogPosition} from '../../types'
|
|
11
|
-
import {Layer, Portal, useLayer} from '../../utils'
|
|
16
|
+
import {Layer, LayerProps, Portal, useBoundaryElement, useLayer, usePortal} from '../../utils'
|
|
12
17
|
import {
|
|
13
18
|
dialogStyle,
|
|
14
19
|
responsiveDialogPositionStyle,
|
|
@@ -34,6 +39,8 @@ export interface DialogProps extends ResponsivePaddingProps, ResponsiveWidthProp
|
|
|
34
39
|
footer?: React.ReactNode
|
|
35
40
|
header?: React.ReactNode
|
|
36
41
|
id: string
|
|
42
|
+
/** A callback that fires when the dialog becomes the top layer when it was not the top layer before. */
|
|
43
|
+
onActivate?: LayerProps['onActivate']
|
|
37
44
|
onClickOutside?: () => void
|
|
38
45
|
onClose?: () => void
|
|
39
46
|
portal?: string
|
|
@@ -58,11 +65,25 @@ interface DialogCardProps extends ResponsiveWidthProps {
|
|
|
58
65
|
id: string
|
|
59
66
|
onClickOutside?: () => void
|
|
60
67
|
onClose?: () => void
|
|
68
|
+
portal?: string
|
|
61
69
|
radius: number | number[]
|
|
62
70
|
scheme?: ThemeColorSchemeKey
|
|
63
71
|
shadow: number | number[]
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
function isTargetWithinScope(
|
|
75
|
+
boundaryElement: HTMLElement | null,
|
|
76
|
+
portalElement: HTMLElement | null,
|
|
77
|
+
target: Node
|
|
78
|
+
): boolean {
|
|
79
|
+
if (!boundaryElement || !portalElement) return true
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
containsOrEqualsElement(boundaryElement, target) ||
|
|
83
|
+
containsOrEqualsElement(portalElement, target)
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
const Root = styled(Layer)<ResponsiveDialogPositionStyleProps & ResponsivePaddingStyleProps>(
|
|
67
88
|
responsivePaddingStyle,
|
|
68
89
|
dialogStyle,
|
|
@@ -138,11 +159,15 @@ const DialogCard = forwardRef(function DialogCard(
|
|
|
138
159
|
id,
|
|
139
160
|
onClickOutside,
|
|
140
161
|
onClose,
|
|
162
|
+
portal: portalProp,
|
|
141
163
|
radius: radiusProp,
|
|
142
164
|
scheme,
|
|
143
165
|
shadow: shadowProp,
|
|
144
166
|
width: widthProp,
|
|
145
167
|
} = props
|
|
168
|
+
const portal = usePortal()
|
|
169
|
+
const portalElement = portalProp ? portal.elements?.[portalProp] || null : portal.element
|
|
170
|
+
const boundaryElement = useBoundaryElement().element
|
|
146
171
|
const radius = useArrayProp(radiusProp)
|
|
147
172
|
const shadow = useArrayProp(shadowProp)
|
|
148
173
|
const width = useArrayProp(widthProp)
|
|
@@ -158,7 +183,7 @@ const DialogCard = forwardRef(function DialogCard(
|
|
|
158
183
|
useEffect(() => {
|
|
159
184
|
if (!autoFocus) return
|
|
160
185
|
|
|
161
|
-
// On mount: focus the first
|
|
186
|
+
// On mount: focus the first focusable element
|
|
162
187
|
if (forwardedRef.current) {
|
|
163
188
|
focusFirstDescendant(forwardedRef.current)
|
|
164
189
|
}
|
|
@@ -169,22 +194,39 @@ const DialogCard = forwardRef(function DialogCard(
|
|
|
169
194
|
(event: KeyboardEvent) => {
|
|
170
195
|
if (!isTopLayer || !onClose) return
|
|
171
196
|
|
|
197
|
+
const target = document.activeElement
|
|
198
|
+
|
|
199
|
+
if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
|
|
200
|
+
// Ignore key presses when the focused element is outside of scope
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
172
204
|
if (event.key === 'Escape') {
|
|
173
205
|
event.preventDefault()
|
|
174
206
|
event.stopPropagation()
|
|
175
207
|
onClose()
|
|
176
208
|
}
|
|
177
209
|
},
|
|
178
|
-
[isTopLayer, onClose]
|
|
210
|
+
[boundaryElement, isTopLayer, onClose, portalElement]
|
|
179
211
|
)
|
|
180
212
|
)
|
|
181
213
|
|
|
182
214
|
useClickOutside(
|
|
183
|
-
useCallback(
|
|
184
|
-
|
|
215
|
+
useCallback(
|
|
216
|
+
(event: MouseEvent) => {
|
|
217
|
+
if (!isTopLayer || !onClickOutside) return
|
|
218
|
+
|
|
219
|
+
const target = event.target as Node | null
|
|
185
220
|
|
|
186
|
-
|
|
187
|
-
|
|
221
|
+
if (target && !isTargetWithinScope(boundaryElement, portalElement, target)) {
|
|
222
|
+
// Ignore clicks outside of the scope
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
onClickOutside()
|
|
227
|
+
},
|
|
228
|
+
[boundaryElement, isTopLayer, onClickOutside, portalElement]
|
|
229
|
+
),
|
|
188
230
|
[rootElement]
|
|
189
231
|
)
|
|
190
232
|
|
|
@@ -265,16 +307,21 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
265
307
|
footer,
|
|
266
308
|
header,
|
|
267
309
|
id,
|
|
310
|
+
onActivate,
|
|
268
311
|
onClickOutside,
|
|
269
312
|
onClose,
|
|
313
|
+
onFocus,
|
|
270
314
|
padding: paddingProp = 4,
|
|
271
|
-
portal,
|
|
315
|
+
portal: portalProp,
|
|
272
316
|
position: positionProp = dialog.position || 'fixed',
|
|
273
317
|
scheme,
|
|
274
318
|
width: widthProp = 0,
|
|
275
319
|
zOffset: zOffsetProp = dialog.zOffset || theme.sanity.layer?.dialog.zOffset,
|
|
276
320
|
...restProps
|
|
277
321
|
} = props
|
|
322
|
+
const portal = usePortal()
|
|
323
|
+
const portalElement = portalProp ? portal.elements?.[portalProp] || null : portal.element
|
|
324
|
+
const boundaryElement = useBoundaryElement().element
|
|
278
325
|
const cardRadius = useArrayProp(cardRadiusProp)
|
|
279
326
|
const padding = useArrayProp(paddingProp)
|
|
280
327
|
const position = useArrayProp(positionProp)
|
|
@@ -283,32 +330,67 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
283
330
|
const preDivRef = useRef<HTMLDivElement | null>(null)
|
|
284
331
|
const postDivRef = useRef<HTMLDivElement | null>(null)
|
|
285
332
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
|
333
|
+
const focusedElementRef = useRef<HTMLElement | null>(null)
|
|
286
334
|
|
|
287
|
-
const handleFocus = useCallback(
|
|
288
|
-
|
|
289
|
-
|
|
335
|
+
const handleFocus = useCallback(
|
|
336
|
+
(event: React.FocusEvent<HTMLDivElement>) => {
|
|
337
|
+
onFocus?.(event)
|
|
290
338
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
339
|
+
const target = event.target
|
|
340
|
+
const cardElement = cardRef.current
|
|
294
341
|
|
|
295
|
-
|
|
296
|
-
|
|
342
|
+
if (cardElement && target === preDivRef.current) {
|
|
343
|
+
focusLastDescendant(cardElement)
|
|
297
344
|
|
|
298
|
-
|
|
299
|
-
|
|
345
|
+
return
|
|
346
|
+
}
|
|
300
347
|
|
|
301
|
-
|
|
302
|
-
|
|
348
|
+
if (cardElement && target === postDivRef.current) {
|
|
349
|
+
focusFirstDescendant(cardElement)
|
|
303
350
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (isHTMLElement(event.target)) {
|
|
355
|
+
focusedElementRef.current = event.target
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
[onFocus]
|
|
359
|
+
)
|
|
307
360
|
|
|
308
361
|
const labelId = `${id}_label`
|
|
309
362
|
|
|
363
|
+
const rootClickTimeoutRef = useRef<NodeJS.Timeout>()
|
|
364
|
+
|
|
365
|
+
// If the resulting active element (a.k.a. focused element) is not withing scope when clicking
|
|
366
|
+
// within the dialog, then we want to focus the previously interactive element in the dialog instead.
|
|
367
|
+
// This is to allow the user to tab or close the dialog by pressing escape.
|
|
368
|
+
const handleRootClick = useCallback(() => {
|
|
369
|
+
if (rootClickTimeoutRef.current) {
|
|
370
|
+
clearTimeout(rootClickTimeoutRef.current)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
rootClickTimeoutRef.current = setTimeout(() => {
|
|
374
|
+
const activeElement = document.activeElement
|
|
375
|
+
|
|
376
|
+
if (activeElement && !isTargetWithinScope(boundaryElement, portalElement, activeElement)) {
|
|
377
|
+
const target = focusedElementRef.current
|
|
378
|
+
|
|
379
|
+
if (!target || !document.body.contains(target)) {
|
|
380
|
+
// No previously focused element, or it's not in the document anymore
|
|
381
|
+
const cardElement = cardRef.current
|
|
382
|
+
if (cardElement) focusFirstDescendant(cardElement)
|
|
383
|
+
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
target.focus()
|
|
388
|
+
}
|
|
389
|
+
}, 0)
|
|
390
|
+
}, [boundaryElement, portalElement])
|
|
391
|
+
|
|
310
392
|
return (
|
|
311
|
-
<Portal __unstable_name={
|
|
393
|
+
<Portal __unstable_name={portalProp}>
|
|
312
394
|
<Root
|
|
313
395
|
{...restProps}
|
|
314
396
|
$padding={padding}
|
|
@@ -317,6 +399,8 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
317
399
|
aria-modal
|
|
318
400
|
data-ui="Dialog"
|
|
319
401
|
id={id}
|
|
402
|
+
onActivate={onActivate}
|
|
403
|
+
onClick={handleRootClick}
|
|
320
404
|
onFocus={handleFocus}
|
|
321
405
|
ref={ref}
|
|
322
406
|
role="dialog"
|
|
@@ -332,6 +416,7 @@ export const Dialog = forwardRef(function Dialog(
|
|
|
332
416
|
id={id}
|
|
333
417
|
onClickOutside={onClickOutside}
|
|
334
418
|
onClose={onClose}
|
|
419
|
+
portal={portalProp}
|
|
335
420
|
radius={cardRadius}
|
|
336
421
|
ref={cardRef}
|
|
337
422
|
scheme={scheme}
|