@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/ui",
3
- "version": "1.1.0",
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.1.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": "^8.5.2",
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.2.6",
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.49.0",
83
- "@typescript-eslint/parser": "^5.49.0",
82
+ "@typescript-eslint/eslint-plugin": "^5.50.0",
83
+ "@typescript-eslint/parser": "^5.50.0",
84
84
  "commitizen": "^4.3.0",
85
- "cypress": "^12.4.0",
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.4",
88
+ "esbuild": "^0.17.5",
89
89
  "esbuild-register": "^3.4.2",
90
- "eslint": "^8.32.0",
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.1",
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.0",
99
+ "jest": "^29.4.1",
100
100
  "jest-axe": "^7.0.0",
101
- "jest-environment-jsdom": "^29.4.0",
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.4",
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.4"
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.0",
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 {focusFirstDescendant, focusLastDescendant} from '../../helpers'
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 interactive element in the contents
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
- if (!isTopLayer || !onClickOutside) return
215
+ useCallback(
216
+ (event: MouseEvent) => {
217
+ if (!isTopLayer || !onClickOutside) return
218
+
219
+ const target = event.target as Node | null
185
220
 
186
- onClickOutside()
187
- }, [isTopLayer, onClickOutside]),
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((event: React.FocusEvent<HTMLDivElement>) => {
288
- const target = event.target
289
- const cardElement = cardRef.current
335
+ const handleFocus = useCallback(
336
+ (event: React.FocusEvent<HTMLDivElement>) => {
337
+ onFocus?.(event)
290
338
 
291
- if (!cardElement) {
292
- return
293
- }
339
+ const target = event.target
340
+ const cardElement = cardRef.current
294
341
 
295
- if (target === preDivRef.current) {
296
- focusLastDescendant(cardElement)
342
+ if (cardElement && target === preDivRef.current) {
343
+ focusLastDescendant(cardElement)
297
344
 
298
- return
299
- }
345
+ return
346
+ }
300
347
 
301
- if (target === postDivRef.current) {
302
- focusFirstDescendant(cardElement)
348
+ if (cardElement && target === postDivRef.current) {
349
+ focusFirstDescendant(cardElement)
303
350
 
304
- return
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={portal}>
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}