Skip to content

Commit e90fb09

Browse files
committed
refactor Dialog to use <dialog> internally
1 parent 341243f commit e90fb09

File tree

6 files changed

+80
-89
lines changed

6 files changed

+80
-89
lines changed

src/ConfirmationDialog/ConfirmationDialog.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ const ShorthandHookFromActionMenu = () => {
7272
}
7373

7474
describe('ConfirmationDialog', () => {
75+
beforeEach(() => {
76+
// Dialog showModal isn't implemented in JSDOM https://github.com/jsdom/jsdom/issues/3294
77+
HTMLDialogElement.prototype.showModal = jest.fn(function mock(this: HTMLDialogElement) {
78+
this.open = true
79+
})
80+
})
81+
7582
behavesAsComponent({
7683
Component: ConfirmationDialog,
7784
toRender: () => <Basic />,

src/ConfirmationDialog/ConfirmationDialog.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface ConfirmationDialogProps {
1616
* Required. This callback is invoked when a gesture to close the dialog
1717
* is performed. The first argument indicates the gesture.
1818
*/
19-
onClose: (gesture: 'confirm' | 'cancel' | 'close-button' | 'cancel' | 'escape') => void
19+
onClose: (gesture: 'confirm' | 'cancel' | 'close-button' | 'escape') => void
2020

2121
/**
2222
* Required. The title of the ConfirmationDialog. This is usually a brief
@@ -145,13 +145,16 @@ export const ConfirmationDialog: React.FC<React.PropsWithChildren<ConfirmationDi
145145
)
146146
}
147147

148+
let hostElement: Element | null = null
148149
export type ConfirmOptions = Omit<ConfirmationDialogProps, 'onClose'> & {content: React.ReactNode}
149150
async function confirm(themeProps: ThemeProviderProps, options: ConfirmOptions): Promise<boolean> {
150151
const {content, ...confirmationDialogProps} = options
151152
return new Promise(resolve => {
152-
const hostElement = document.createElement('div')
153+
hostElement ||= document.createElement('div')
154+
if (!hostElement.isConnected) document.body.append(hostElement)
153155
const onClose: ConfirmationDialogProps['onClose'] = gesture => {
154-
ReactDOM.unmountComponentAtNode(hostElement)
156+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
157+
ReactDOM.unmountComponentAtNode(hostElement!)
155158
if (gesture === 'confirm') {
156159
resolve(true)
157160
} else {

src/DataTable/__tests__/ErrorDialog.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import React from 'react'
44
import {ErrorDialog} from '../ErrorDialog'
55

66
describe('Table.ErrorDialog', () => {
7+
beforeEach(() => {
8+
// Dialog showModal isn't implemented in JSDOM https://github.com/jsdom/jsdom/issues/3294
9+
HTMLDialogElement.prototype.showModal = jest.fn(function mock(this: HTMLDialogElement) {
10+
this.open = true
11+
})
12+
})
13+
714
it('should use a default title of "Error" if `title` is not provided', () => {
815
render(<ErrorDialog />)
916
expect(

src/Dialog/Dialog.tsx

Lines changed: 52 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
import React, {useCallback, useEffect, useRef, useState} from 'react'
1+
import React, {useCallback, useRef} from 'react'
22
import styled from 'styled-components'
33
import Button, {ButtonPrimary, ButtonDanger, ButtonProps} from '../deprecated/Button'
44
import Box from '../Box'
55
import {get} from '../constants'
6-
import {useOnEscapePress, useProvidedRefOrCreate} from '../hooks'
7-
import {useFocusTrap} from '../hooks/useFocusTrap'
6+
import {useOnEscapePress} from '../hooks'
87
import sx, {SxProp} from '../sx'
98
import Octicon from '../Octicon'
109
import {XIcon} from '@primer/octicons-react'
1110
import {useFocusZone} from '../hooks/useFocusZone'
1211
import {FocusKeys} from '@primer/behaviors'
13-
import Portal from '../Portal'
1412
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
1513
import {useId} from '../hooks/useId'
14+
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
1615

1716
/* Dialog Version 2 */
1817

@@ -125,6 +124,11 @@ export interface DialogProps extends SxProp {
125124
* auto: variable based on contents
126125
*/
127126
height?: DialogHeight
127+
128+
/**
129+
* Whether or not the Dialog is rendered initially open
130+
*/
131+
initiallyOpen?: boolean
128132
}
129133

130134
/**
@@ -144,32 +148,11 @@ export interface DialogHeaderProps extends DialogProps {
144148
dialogDescriptionId: string
145149
}
146150

147-
const Backdrop = styled('div')`
148-
position: fixed;
149-
top: 0;
150-
left: 0;
151-
bottom: 0;
152-
right: 0;
153-
display: flex;
154-
align-items: center;
155-
justify-content: center;
156-
background-color: ${get('colors.primer.canvas.backdrop')};
157-
animation: dialog-backdrop-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
158-
159-
@keyframes dialog-backdrop-appear {
160-
0% {
161-
opacity: 0;
162-
}
163-
100% {
164-
opacity: 1;
165-
}
166-
}
167-
`
168-
169151
const heightMap = {
170152
small: '480px',
171153
large: '640px',
172-
auto: 'auto',
154+
auto: 'min-content',
155+
minConte,
173156
} as const
174157

175158
const widthMap = {
@@ -187,8 +170,7 @@ type StyledDialogProps = {
187170
height?: DialogHeight
188171
} & SxProp
189172

190-
const StyledDialog = styled.div<StyledDialogProps>`
191-
display: flex;
173+
const StyledDialog = styled.dialog<StyledDialogProps>`
192174
flex-direction: column;
193175
background-color: ${get('colors.canvas.overlay')};
194176
box-shadow: ${get('shadows.overlay.shadow')};
@@ -197,10 +179,30 @@ const StyledDialog = styled.div<StyledDialogProps>`
197179
max-height: calc(100vh - 64px);
198180
width: ${props => widthMap[props.width ?? ('xlarge' as const)]};
199181
height: ${props => heightMap[props.height ?? ('auto' as const)]};
182+
border: 0;
200183
border-radius: 12px;
184+
padding: 0;
201185
opacity: 1;
202186
animation: overlay--dialog-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
203187
188+
&[open] {
189+
display: flex;
190+
}
191+
192+
&::backdrop {
193+
background-color: ${get('colors.primer.canvas.backdrop')};
194+
animation: dialog-backdrop-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
195+
}
196+
197+
@keyframes dialog-backdrop-appear {
198+
0% {
199+
opacity: 0;
200+
}
201+
100% {
202+
opacity: 1;
203+
}
204+
}
205+
204206
@keyframes overlay--dialog-appear {
205207
0% {
206208
opacity: 0;
@@ -252,7 +254,7 @@ const DefaultFooter: React.FC<React.PropsWithChildren<DialogProps>> = ({footerBu
252254
) : null
253255
}
254256

255-
const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogProps>>((props, forwardedRef) => {
257+
const _Dialog = React.forwardRef<HTMLDialogElement, React.PropsWithChildren<DialogProps>>((props, forwardedRef) => {
256258
const {
257259
title = 'Dialog',
258260
subtitle = '',
@@ -264,6 +266,7 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
264266
width = 'xlarge',
265267
height = 'auto',
266268
footerButtons = [],
269+
initiallyOpen = true,
267270
sx,
268271
} = props
269272
const dialogLabelId = useId()
@@ -276,10 +279,8 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
276279
}
277280
const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId}
278281

279-
const dialogRef = useRef<HTMLDivElement>(null)
282+
const dialogRef = useRef<HTMLDialogElement>(null)
280283
useRefObjectAsForwardedRef(forwardedRef, dialogRef)
281-
const backdropRef = useRef<HTMLDivElement>(null)
282-
useFocusTrap({containerRef: dialogRef, restoreFocusOnCleanUp: true, initialFocusRef: autoFocusedFooterButtonRef})
283284

284285
useOnEscapePress(
285286
(event: KeyboardEvent) => {
@@ -289,47 +290,30 @@ const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogP
289290
[onClose],
290291
)
291292

292-
React.useEffect(() => {
293-
const bodyOverflowStyle = document.body.style.overflow || ''
294-
// If the body is already set to overflow: hidden, it likely means
295-
// that there is already a modal open. In that case, we should bail
296-
// so we don't re-enable scroll after the second dialog is closed.
297-
if (bodyOverflowStyle === 'hidden') {
298-
return
299-
}
300-
301-
document.body.style.overflow = 'hidden'
302-
303-
return () => {
304-
document.body.style.overflow = bodyOverflowStyle
293+
useIsomorphicLayoutEffect(() => {
294+
if (initiallyOpen && !dialogRef.current?.open && dialogRef.current?.isConnected) {
295+
dialogRef.current.showModal()
305296
}
306-
}, [])
297+
}, [initiallyOpen, dialogRef])
307298

308299
const header = (renderHeader ?? DefaultHeader)(defaultedProps)
309300
const body = (renderBody ?? DefaultBody)(defaultedProps)
310301
const footer = (renderFooter ?? DefaultFooter)(defaultedProps)
311302

312303
return (
313-
<>
314-
<Portal>
315-
<Backdrop ref={backdropRef}>
316-
<StyledDialog
317-
width={width}
318-
height={height}
319-
ref={dialogRef}
320-
role={role}
321-
aria-labelledby={dialogLabelId}
322-
aria-describedby={dialogDescriptionId}
323-
aria-modal
324-
sx={sx}
325-
>
326-
{header}
327-
{body}
328-
{footer}
329-
</StyledDialog>
330-
</Backdrop>
331-
</Portal>
332-
</>
304+
<StyledDialog
305+
width={width}
306+
height={height === 'auto' ? 'min-content' : height}
307+
ref={dialogRef}
308+
role={role === 'alertdialog' ? role : undefined}
309+
aria-labelledby={dialogLabelId}
310+
aria-describedby={dialogDescriptionId}
311+
sx={sx}
312+
>
313+
{header}
314+
{body}
315+
{footer}
316+
</StyledDialog>
333317
)
334318
})
335319
_Dialog.displayName = 'Dialog'
@@ -390,30 +374,13 @@ const buttonTypes = {
390374
danger: ButtonDanger,
391375
}
392376
const Buttons: React.FC<React.PropsWithChildren<{buttons: DialogButtonProps[]}>> = ({buttons}) => {
393-
const autoFocusRef = useProvidedRefOrCreate<HTMLButtonElement>(buttons.find(button => button.autoFocus)?.ref)
394-
let autoFocusCount = 0
395-
const [hasRendered, setHasRendered] = useState(0)
396-
useEffect(() => {
397-
// hack to work around dialogs originating from other focus traps.
398-
if (hasRendered === 1) {
399-
autoFocusRef.current?.focus()
400-
} else {
401-
setHasRendered(hasRendered + 1)
402-
}
403-
}, [autoFocusRef, hasRendered])
404-
405377
return (
406378
<>
407379
{buttons.map((dialogButtonProps, index) => {
408380
const {content, buttonType = 'normal', autoFocus = false, ...buttonProps} = dialogButtonProps
409381
const ButtonElement = buttonTypes[buttonType]
410382
return (
411-
<ButtonElement
412-
key={index}
413-
{...buttonProps}
414-
variant={buttonType}
415-
ref={autoFocus && autoFocusCount === 0 ? (autoFocusCount++, autoFocusRef) : null}
416-
>
383+
<ButtonElement key={index} {...buttonProps} variant={buttonType} autoFocus={autoFocus}>
417384
{content}
418385
</ButtonElement>
419386
)

src/TreeView/TreeView.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,6 +1177,13 @@ describe('State', () => {
11771177
})
11781178

11791179
describe('Asyncronous loading', () => {
1180+
beforeEach(() => {
1181+
// Dialog showModal isn't implemented in JSDOM https://github.com/jsdom/jsdom/issues/3294
1182+
HTMLDialogElement.prototype.showModal = jest.fn(function mock(this: HTMLDialogElement) {
1183+
this.open = true
1184+
})
1185+
})
1186+
11801187
it('updates aria live region when loading is done', () => {
11811188
function TestTree() {
11821189
const [state, setState] = React.useState<SubTreeState>('loading')

src/drafts/Button2/Button.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export default {
6060
export const Playground: StoryFn = args => {
6161
const {trailingVisualCount, ...rest} = args
6262
return (
63-
<Button {...rest}>
63+
<Button {...rest} autoFocus={true}>
6464
Default
6565
{typeof trailingVisualCount === 'undefined' ? null : <Button.Counter>{trailingVisualCount}</Button.Counter>}
6666
</Button>

0 commit comments

Comments
 (0)