Skip to content

Commit 953f2bc

Browse files
committed
refactor Dialog to use <dialog> internally
1 parent 5f94644 commit 953f2bc

File tree

4 files changed

+95
-97
lines changed

4 files changed

+95
-97
lines changed

src/ConfirmationDialog/ConfirmationDialog.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {FocusKeys} from '@primer/behaviors'
77
import {get} from '../constants'
88
import {Dialog, DialogProps, DialogHeaderProps, DialogButtonProps} from '../Dialog/Dialog'
99
import {useFocusZone} from '../hooks/useFocusZone'
10+
import BaseStyles from '../BaseStyles'
1011

1112
/**
1213
* Props to customize the ConfirmationDialog.
@@ -150,11 +151,12 @@ export type ConfirmOptions = Omit<ConfirmationDialogProps, 'onClose'> & {content
150151
async function confirm(themeProps: ThemeProviderProps, options: ConfirmOptions): Promise<boolean> {
151152
const {content, ...confirmationDialogProps} = options
152153
return new Promise(resolve => {
153-
hostElement = document.createElement('div')
154+
hostElement ||= document.createElement('div')
154155
if (!hostElement.isConnected) document.body.append(hostElement)
155156
const root = createRoot(hostElement)
156157
const onClose: ConfirmationDialogProps['onClose'] = gesture => {
157158
root.unmount()
159+
158160
if (gesture === 'confirm') {
159161
resolve(true)
160162
} else {
@@ -163,9 +165,11 @@ async function confirm(themeProps: ThemeProviderProps, options: ConfirmOptions):
163165
}
164166
root.render(
165167
<ThemeProvider {...themeProps}>
166-
<ConfirmationDialog {...confirmationDialogProps} onClose={onClose}>
167-
{content}
168-
</ConfirmationDialog>
168+
<BaseStyles>
169+
<ConfirmationDialog {...confirmationDialogProps} onClose={onClose}>
170+
{content}
171+
</ConfirmationDialog>
172+
</BaseStyles>
169173
</ThemeProvider>,
170174
)
171175
})

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+
// In React `autoFocus` calls `.focus()` on mount, but does not set the `autofocus` attribute.
9+
// Setting `autofocus=` in JSX will console.error saying "Did you mean autofocus" which fails
10+
// the Jest Test, unless we mock console.error...
11+
jest.spyOn(console, 'error').mockImplementation()
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: 71 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
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'
86
import sx, {SxProp} from '../sx'
97
import Octicon from '../Octicon'
108
import {XIcon} from '@primer/octicons-react'
119
import {useFocusZone} from '../hooks/useFocusZone'
1210
import {FocusKeys} from '@primer/behaviors'
13-
import Portal from '../Portal'
1411
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
1512
import {useId} from '../hooks/useId'
13+
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
1614

1715
/* Dialog Version 2 */
1816

@@ -95,13 +93,23 @@ export interface DialogProps extends SxProp {
9593
footerButtons?: DialogButtonProps[]
9694

9795
/**
98-
* This method is invoked when a gesture to close the dialog is used (either
99-
* an Escape key press or clicking the "X" in the top-right corner). The
100-
* gesture argument indicates the gesture that was used to close the dialog
101-
* (either 'close-button' or 'escape').
96+
* This method is invoked when the dialog has been closed. This could
97+
* be from the Dialog.
98+
* @param gesture - Deprecated: The gesture argument was used to
99+
* indicate if the gesture was from the close-button or escape button.
100+
* It will always be `'close-button'`, to check for _cancelations_ of
101+
* the dialog using the Esc key, use onCancel.
102102
*/
103103
onClose: (gesture: 'close-button' | 'escape') => void
104104

105+
/**
106+
* This method is invoked when the user instructs the browser they wish to
107+
* dismiss the dialog. Typically this means they have pressed the `Esc` key.
108+
*
109+
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/cancel_event
110+
*/
111+
onCancel?: () => void
112+
105113
/**
106114
* Default: "dialog". The ARIA role to assign to this dialog.
107115
* @see https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal
@@ -125,6 +133,11 @@ export interface DialogProps extends SxProp {
125133
* auto: variable based on contents
126134
*/
127135
height?: DialogHeight
136+
137+
/**
138+
* Whether or not the Dialog is rendered initially open
139+
*/
140+
initiallyOpen?: boolean
128141
}
129142

130143
/**
@@ -144,32 +157,11 @@ export interface DialogHeaderProps extends DialogProps {
144157
dialogDescriptionId: string
145158
}
146159

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-
169160
const heightMap = {
170161
small: '480px',
171162
large: '640px',
172163
auto: 'auto',
164+
'min-content': 'min-content',
173165
} as const
174166

175167
const widthMap = {
@@ -187,19 +179,40 @@ type StyledDialogProps = {
187179
height?: DialogHeight
188180
} & SxProp
189181

190-
const StyledDialog = styled.div<StyledDialogProps>`
191-
display: flex;
182+
const StyledDialog = styled.dialog<StyledDialogProps>`
192183
flex-direction: column;
193184
background-color: ${get('colors.canvas.overlay')};
185+
color: ${get('colors.fg.default')};
194186
box-shadow: ${get('shadows.overlay.shadow')};
195187
min-width: 296px;
196188
max-width: calc(100vw - 64px);
197189
max-height: calc(100vh - 64px);
198190
width: ${props => widthMap[props.width ?? ('xlarge' as const)]};
199191
height: ${props => heightMap[props.height ?? ('auto' as const)]};
192+
border: 0;
200193
border-radius: 12px;
194+
padding: 0;
201195
opacity: 1;
202196
animation: overlay--dialog-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
197+
overflow: initial;
198+
199+
&[open] {
200+
display: flex;
201+
}
202+
203+
&::backdrop {
204+
background-color: ${get('colors.primer.canvas.backdrop')};
205+
animation: dialog-backdrop-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
206+
}
207+
208+
@keyframes dialog-backdrop-appear {
209+
0% {
210+
opacity: 0;
211+
}
212+
100% {
213+
opacity: 1;
214+
}
215+
}
203216
204217
@keyframes overlay--dialog-appear {
205218
0% {
@@ -252,84 +265,56 @@ const DefaultFooter: React.FC<React.PropsWithChildren<DialogProps>> = ({footerBu
252265
) : null
253266
}
254267

255-
const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogProps>>((props, forwardedRef) => {
268+
const _Dialog = React.forwardRef<HTMLDialogElement, React.PropsWithChildren<DialogProps>>((props, forwardedRef) => {
256269
const {
257270
title = 'Dialog',
258271
subtitle = '',
259272
renderHeader,
260273
renderBody,
261274
renderFooter,
262275
onClose,
276+
onCancel,
263277
role = 'dialog',
264278
width = 'xlarge',
265279
height = 'auto',
266-
footerButtons = [],
280+
initiallyOpen = true,
267281
sx,
268282
} = props
269283
const dialogLabelId = useId()
270284
const dialogDescriptionId = useId()
271-
const autoFocusedFooterButtonRef = useRef<HTMLButtonElement>(null)
272-
for (const footerButton of footerButtons) {
273-
if (footerButton.autoFocus) {
274-
footerButton.ref = autoFocusedFooterButtonRef
275-
}
276-
}
277285
const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId}
278286

279-
const dialogRef = useRef<HTMLDivElement>(null)
287+
const dialogRef = useRef<HTMLDialogElement>(null)
280288
useRefObjectAsForwardedRef(forwardedRef, dialogRef)
281-
const backdropRef = useRef<HTMLDivElement>(null)
282-
useFocusTrap({containerRef: dialogRef, restoreFocusOnCleanUp: true, initialFocusRef: autoFocusedFooterButtonRef})
283-
284-
useOnEscapePress(
285-
(event: KeyboardEvent) => {
286-
onClose('escape')
287-
event.preventDefault()
288-
},
289-
[onClose],
290-
)
291289

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
290+
useIsomorphicLayoutEffect(() => {
291+
if (initiallyOpen && !dialogRef.current?.open && dialogRef.current?.isConnected) {
292+
dialogRef.current.showModal()
299293
}
294+
}, [initiallyOpen, dialogRef])
300295

301-
document.body.style.overflow = 'hidden'
302-
303-
return () => {
304-
document.body.style.overflow = bodyOverflowStyle
305-
}
306-
}, [])
296+
const onCloseHandler = useCallback(() => onClose('close-button'), [onClose])
307297

308298
const header = (renderHeader ?? DefaultHeader)(defaultedProps)
309299
const body = (renderBody ?? DefaultBody)(defaultedProps)
310300
const footer = (renderFooter ?? DefaultFooter)(defaultedProps)
311301

312302
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-
</>
303+
<StyledDialog
304+
width={width}
305+
height={height === 'auto' ? 'min-content' : height}
306+
ref={dialogRef}
307+
role={role}
308+
aria-labelledby={dialogLabelId}
309+
aria-describedby={dialogDescriptionId}
310+
sx={sx}
311+
onCancel={onCancel}
312+
onClose={onCloseHandler}
313+
>
314+
{header}
315+
{body}
316+
{footer}
317+
</StyledDialog>
333318
)
334319
})
335320
_Dialog.displayName = 'Dialog'
@@ -385,17 +370,10 @@ const buttonTypes = {
385370
}
386371
const Buttons: React.FC<React.PropsWithChildren<{buttons: DialogButtonProps[]}>> = ({buttons}) => {
387372
const autoFocusRef = useProvidedRefOrCreate<HTMLButtonElement>(buttons.find(button => button.autoFocus)?.ref)
388-
let autoFocusCount = 0
389373
const [hasRendered, setHasRendered] = useState(0)
390-
useEffect(() => {
391-
// hack to work around dialogs originating from other focus traps.
392-
if (hasRendered === 1) {
393-
autoFocusRef.current?.focus()
394-
} else {
395-
setHasRendered(hasRendered + 1)
396-
}
374+
useIsomorphicLayoutEffect(() => {
375+
autoFocusRef.current?.setAttribute('autofocus')
397376
}, [autoFocusRef, hasRendered])
398-
399377
return (
400378
<>
401379
{buttons.map((dialogButtonProps, index) => {

src/utils/test-helpers.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@ global.CSS = {
1818
}
1919

2020
global.TextEncoder = TextEncoder
21+
22+
// Dialog showModal isn't implemented in JSDOM https://github.com/jsdom/jsdom/issues/3294
23+
global.HTMLDialogElement.prototype.showModal = jest.fn(function mock(this: HTMLDialogElement) {
24+
// eslint-disable-next-line no-invalid-this
25+
this.open = true
26+
// eslint-disable-next-line no-invalid-this
27+
const element: HTMLElement | null = this.querySelector('[autofocus]')
28+
element?.focus()
29+
})

0 commit comments

Comments
 (0)