Skip to content

Commit fb87988

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

File tree

3 files changed

+91
-99
lines changed

3 files changed

+91
-99
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/Dialog/Dialog.tsx

Lines changed: 74 additions & 95 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'
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'
14-
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
11+
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
1512
import {useId} from '../hooks/useId'
13+
import {useProvidedRefOrCreate} from '../hooks/useProvidedRefOrCreate'
14+
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
1615

1716
/* Dialog Version 2 */
1817

@@ -95,13 +94,23 @@ export interface DialogProps extends SxProp {
9594
footerButtons?: DialogButtonProps[]
9695

9796
/**
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').
97+
* This method is invoked when the dialog has been closed. This could
98+
* be from the Dialog.
99+
* @param gesture - Deprecated: The gesture argument was used to
100+
* indicate if the gesture was from the close-button or escape button.
101+
* It will always be `'close-button'`, to check for _cancelations_ of
102+
* the dialog using the Esc key, use onCancel.
102103
*/
103104
onClose: (gesture: 'close-button' | 'escape') => void
104105

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

130144
/**
@@ -144,32 +158,11 @@ export interface DialogHeaderProps extends DialogProps {
144158
dialogDescriptionId: string
145159
}
146160

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-
169161
const heightMap = {
170162
small: '480px',
171163
large: '640px',
172164
auto: 'auto',
165+
'min-content': 'min-content',
173166
} as const
174167

175168
const widthMap = {
@@ -187,19 +180,40 @@ type StyledDialogProps = {
187180
height?: DialogHeight
188181
} & SxProp
189182

190-
const StyledDialog = styled.div<StyledDialogProps>`
191-
display: flex;
183+
const StyledDialog = styled.dialog<StyledDialogProps>`
192184
flex-direction: column;
193185
background-color: ${get('colors.canvas.overlay')};
186+
color: ${get('colors.fg.default')};
194187
box-shadow: ${get('shadows.overlay.shadow')};
195188
min-width: 296px;
196189
max-width: calc(100vw - 64px);
197190
max-height: calc(100vh - 64px);
198191
width: ${props => widthMap[props.width ?? ('xlarge' as const)]};
199192
height: ${props => heightMap[props.height ?? ('auto' as const)]};
193+
border: 0;
200194
border-radius: 12px;
195+
padding: 0;
201196
opacity: 1;
202197
animation: overlay--dialog-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
198+
overflow: initial;
199+
200+
&[open] {
201+
display: flex;
202+
}
203+
204+
&::backdrop {
205+
background-color: ${get('colors.primer.canvas.backdrop')};
206+
animation: dialog-backdrop-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')};
207+
}
208+
209+
@keyframes dialog-backdrop-appear {
210+
0% {
211+
opacity: 0;
212+
}
213+
100% {
214+
opacity: 1;
215+
}
216+
}
203217
204218
@keyframes overlay--dialog-appear {
205219
0% {
@@ -252,84 +266,56 @@ const DefaultFooter: React.FC<React.PropsWithChildren<DialogProps>> = ({footerBu
252266
) : null
253267
}
254268

255-
const _Dialog = React.forwardRef<HTMLDivElement, React.PropsWithChildren<DialogProps>>((props, forwardedRef) => {
269+
const _Dialog = React.forwardRef<HTMLDialogElement, React.PropsWithChildren<DialogProps>>((props, forwardedRef) => {
256270
const {
257271
title = 'Dialog',
258272
subtitle = '',
259273
renderHeader,
260274
renderBody,
261275
renderFooter,
262276
onClose,
277+
onCancel,
263278
role = 'dialog',
264279
width = 'xlarge',
265280
height = 'auto',
266-
footerButtons = [],
281+
initiallyOpen = true,
267282
sx,
268283
} = props
269284
const dialogLabelId = useId()
270285
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-
}
277286
const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId}
278287

279-
const dialogRef = useRef<HTMLDivElement>(null)
288+
const dialogRef = useRef<HTMLDialogElement>(null)
280289
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-
)
291290

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
291+
useIsomorphicLayoutEffect(() => {
292+
if (initiallyOpen && !dialogRef.current?.open && dialogRef.current?.isConnected) {
293+
dialogRef.current.showModal()
299294
}
295+
}, [initiallyOpen, dialogRef])
300296

301-
document.body.style.overflow = 'hidden'
302-
303-
return () => {
304-
document.body.style.overflow = bodyOverflowStyle
305-
}
306-
}, [])
297+
const onCloseHandler = useCallback(() => onClose('close-button'), [onClose])
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}
309+
aria-labelledby={dialogLabelId}
310+
aria-describedby={dialogDescriptionId}
311+
sx={sx}
312+
onCancel={onCancel}
313+
onClose={onCloseHandler}
314+
>
315+
{header}
316+
{body}
317+
{footer}
318+
</StyledDialog>
333319
)
334320
})
335321
_Dialog.displayName = 'Dialog'
@@ -386,16 +372,9 @@ const buttonTypes = {
386372
const Buttons: React.FC<React.PropsWithChildren<{buttons: DialogButtonProps[]}>> = ({buttons}) => {
387373
const autoFocusRef = useProvidedRefOrCreate<HTMLButtonElement>(buttons.find(button => button.autoFocus)?.ref)
388374
let autoFocusCount = 0
389-
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-
}
397-
}, [autoFocusRef, hasRendered])
398-
375+
useIsomorphicLayoutEffect(() => {
376+
autoFocusRef.current?.setAttribute('autofocus', '')
377+
}, [autoFocusRef])
399378
return (
400379
<>
401380
{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)