diff --git a/.changeset/strange-geese-drive.md b/.changeset/strange-geese-drive.md new file mode 100644 index 00000000000..d8837632b56 --- /dev/null +++ b/.changeset/strange-geese-drive.md @@ -0,0 +1,7 @@ +--- +'@primer/react': major +--- + +Moves the new Dialog and ConfirmationDialog component to the drafts directory + + \ No newline at end of file diff --git a/docs/content/Dialog.mdx b/docs/content/Dialog.mdx index 5598b90d17c..76465d6df5f 100644 --- a/docs/content/Dialog.mdx +++ b/docs/content/Dialog.mdx @@ -4,7 +4,7 @@ status: Alpha source: https://github.com/primer/react/blob/main/src/Dialog --- -import data from '../../src/Dialog.docs.json' +import data from '../../src/Dialog/Dialog.docs.json' The dialog component is used for all modals. It renders on top of the rest of the app with an overlay. diff --git a/docs/content/drafts/Dialog.mdx b/docs/content/drafts/Dialog.mdx index 3db183706d0..7c95e60c6a8 100644 --- a/docs/content/drafts/Dialog.mdx +++ b/docs/content/drafts/Dialog.mdx @@ -4,7 +4,7 @@ componentId: dialog status: Draft --- -import data from '../../../src/Dialog/Dialog.docs.json' +import data from '../../../src/drafts/Dialog2/Dialog.docs.json' ```js import {Dialog} from '@primer/react/drafts' diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml index 975b34163f6..59929e6bc98 100644 --- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml +++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml @@ -70,8 +70,6 @@ url: /CounterLabel - title: Details url: /Details - - title: Dialog - url: /Dialog - title: Flash url: /Flash - title: FormControl diff --git a/src/DataTable/ErrorDialog.tsx b/src/DataTable/ErrorDialog.tsx index 2ff534249b4..c07b7a44632 100644 --- a/src/DataTable/ErrorDialog.tsx +++ b/src/DataTable/ErrorDialog.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {ConfirmationDialog} from '../Dialog/ConfirmationDialog' +import {ConfirmationDialog} from '../drafts/Dialog2/ConfirmationDialog' export type TableErrorDialogProps = React.PropsWithChildren<{ /** diff --git a/src/Dialog.docs.json b/src/Dialog.docs.json deleted file mode 100644 index 593f3688b3f..00000000000 --- a/src/Dialog.docs.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "id": "dialog", - "name": "Dialog", - "status": "alpha", - "a11yReviewed": false, - "stories": [], - "props": [ - { - "name": "isOpen", - "type": "boolean", - "description": "Whether or not the dialog is open" - }, - { - "name": "onDismiss", - "type": "() => void", - "description": "Function that will be called when the dialog is closed" - }, - { - "name": "returnFocusRef", - "type": " React.RefObject", - "description": "The element to restore focus back to after the `Dialog` is closed" - }, - { - "name": "initialFocusRef", - "type": " React.RefObject", - "description": "Element inside of the `Dialog` you'd like to be focused when the Dialog is opened. If nothing is passed to `initialFocusRef` the close button is focused." - }, - { - "name": "aria-labelledby", - "type": "string", - "description": "Pass an id to use for the aria-label. Use either a `aria-label` or an `aria-labelledby` but not both." - }, - { - "name": "aria-label", - "type": "string", - "description": "Pass a label to be used to describe the Dialog. Use either a `aria-label` or an `aria-labelledby` but not both." - }, - { - "name": "sx", - "type": "SystemStyleObject" - } - ], - "subcomponents": [ - { - "name": "Dialog.Header", - "props": [ - { - "name": "sx", - "type": "SystemStyleObject" - } - ] - } - ] -} diff --git a/src/Dialog.tsx b/src/Dialog.tsx deleted file mode 100644 index 2487bc2fe34..00000000000 --- a/src/Dialog.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React, {forwardRef, useRef} from 'react' -import styled from 'styled-components' -import {IconButton} from './Button' -import {get} from './constants' -import Box from './Box' -import useDialog from './hooks/useDialog' -import sx, {SxProp} from './sx' -import Text from './Text' -import {ComponentProps} from './utils/types' -import {useRefObjectAsForwardedRef} from './hooks/useRefObjectAsForwardedRef' -import {XIcon} from '@primer/octicons-react' - -const noop = () => null - -type StyledDialogBaseProps = { - narrow?: boolean - wide?: boolean -} & SxProp - -const DialogBase = styled.div` - box-shadow: ${get('shadows.shadow.large')}; - border-radius: ${get('radii.2')}; - position: fixed; - top: 0; - left: 50%; - transform: translateX(-50%); - max-height: 80vh; - z-index: 999; - margin: 10vh auto; - background-color: ${get('colors.canvas.default')}; - width: ${props => (props.narrow ? '320px' : props.wide ? '640px' : '440px')}; - outline: none; - - @media screen and (max-width: 750px) { - width: 100vw; - margin: 0; - border-radius: 0; - height: 100vh; - } - - ${sx}; -` - -const DialogHeaderBase = styled(Box)` - border-radius: ${get('radii.2')} ${get('radii.2')} 0px 0px; - border-bottom: 1px solid ${get('colors.border.default')}; - display: flex; - - @media screen and (max-width: 750px) { - border-radius: 0px; - } - - ${sx}; -` -export type DialogHeaderProps = ComponentProps - -function DialogHeader({theme, children, backgroundColor = 'canvas.subtle', ...rest}: DialogHeaderProps) { - if (React.Children.toArray(children).every(ch => typeof ch === 'string')) { - children = ( - - {children} - - ) - } - - return ( - - {children} - - ) -} - -const Overlay = styled.span` - &:before { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: block; - cursor: default; - content: ' '; - background: transparent; - z-index: 99; - background: ${get('colors.primer.canvas.backdrop')}; - } -` - -type InternalDialogProps = { - isOpen?: boolean - onDismiss?: () => void - initialFocusRef?: React.RefObject - returnFocusRef?: React.RefObject -} & ComponentProps - -const Dialog = forwardRef( - ({children, onDismiss = noop, isOpen, initialFocusRef, returnFocusRef, ...props}, forwardedRef) => { - const overlayRef = useRef(null) - const modalRef = useRef(null) - useRefObjectAsForwardedRef(forwardedRef, modalRef) - const closeButtonRef = useRef(null) - - const onCloseClick = () => { - onDismiss() - if (returnFocusRef && returnFocusRef.current) { - returnFocusRef.current.focus() - } - } - - const {getDialogProps} = useDialog({ - modalRef, - onDismiss: onCloseClick, - isOpen, - initialFocusRef, - closeButtonRef, - returnFocusRef, - overlayRef, - }) - return isOpen ? ( - <> - - - - {children} - - - ) : null - }, -) - -DialogHeader.propTypes = { - ...Box.propTypes, -} - -DialogHeader.displayName = 'Dialog.Header' -Dialog.displayName = 'Dialog' - -export type DialogProps = ComponentProps -export default Object.assign(Dialog, {Header: DialogHeader}) diff --git a/src/Dialog/Dialog.docs.json b/src/Dialog/Dialog.docs.json index 5b803588341..593f3688b3f 100644 --- a/src/Dialog/Dialog.docs.json +++ b/src/Dialog/Dialog.docs.json @@ -1,58 +1,54 @@ { - "id": "drafts_dialog", + "id": "dialog", "name": "Dialog", - "status": "draft", + "status": "alpha", "a11yReviewed": false, "stories": [], "props": [ { - "name": "title", - "type": "React.ReactNode", - "description": "Title of the Dialog. Also serves as the aria-label for this Dialog." + "name": "isOpen", + "type": "boolean", + "description": "Whether or not the dialog is open" }, { - "name": "subtitle", - "type": "React.ReactNode", - "description": "The Dialog's subtitle. Optional. Rendered below the title in smaller type with less contrast. Also serves as the aria-describedby for this Dialog." + "name": "onDismiss", + "type": "() => void", + "description": "Function that will be called when the dialog is closed" }, { - "name": "renderHeader", - "type": "React.FunctionComponent>", - "description": "Provide a custom renderer for the dialog header. This content is rendered directly into the dialog body area, full bleed from edge to edge, top to the start of the body element. Warning: using a custom renderer may violate Primer UX principles." + "name": "returnFocusRef", + "type": " React.RefObject", + "description": "The element to restore focus back to after the `Dialog` is closed" }, { - "name": "renderBody", - "type": "React.FunctionComponent>", - "description": "Provide a custom render function for the dialog body. This content is rendered directly into the dialog body area, full bleed from edge to edge, header to footer. Warning: using a custom renderer may violate Primer UX principles." + "name": "initialFocusRef", + "type": " React.RefObject", + "description": "Element inside of the `Dialog` you'd like to be focused when the Dialog is opened. If nothing is passed to `initialFocusRef` the close button is focused." }, { - "name": "renderFooter", - "type": "React.FunctionComponent>", - "description": "Provide a custom render function for the dialog footer. This content is rendered directly into the dialog footer area, full bleed from edge to edge, end of the body element to bottom. Warning: using a custom renderer may violate Primer UX principles." + "name": "aria-labelledby", + "type": "string", + "description": "Pass an id to use for the aria-label. Use either a `aria-label` or an `aria-labelledby` but not both." }, { - "name": "footerButtons", - "type": "DialogButtonProps[]", - "description": "Specifies the buttons to be rendered in the Dialog footer." + "name": "aria-label", + "type": "string", + "description": "Pass a label to be used to describe the Dialog. Use either a `aria-label` or an `aria-labelledby` but not both." }, { - "name": "onClose", - "type": "(gesture: 'close-button' | 'escape') => void", - "description": "This method is invoked when a gesture to close the dialog is used (either an Escape key press or clicking the 'X' in the top-right corner). The gesture argument indicates the gesture that was used to close the dialog (either 'close-button' or 'escape')." - }, - { - "name": "role", - "type": "'dialog' | 'alertdialog'", - "description": "The ARIA role to assign to this dialog." - }, - { - "name": "width", - "type": "'small' | 'medium' | 'large' | 'xlarge'" - }, - { - "name": "height", - "type": "'small' | 'large' | 'auto'" + "name": "sx", + "type": "SystemStyleObject" } ], - "subcomponents": [] + "subcomponents": [ + { + "name": "Dialog.Header", + "props": [ + { + "name": "sx", + "type": "SystemStyleObject" + } + ] + } + ] } diff --git a/src/__tests__/Dialog.test.tsx b/src/Dialog/Dialog.test.tsx similarity index 98% rename from src/__tests__/Dialog.test.tsx rename to src/Dialog/Dialog.test.tsx index 5d4e51078f6..a9d94dc4323 100644 --- a/src/__tests__/Dialog.test.tsx +++ b/src/Dialog/Dialog.test.tsx @@ -1,5 +1,5 @@ import React, {useState, useRef} from 'react' -import {Dialog, Box, Text} from '..' +import {Box, Dialog, Text} from '..' import {Button} from '../deprecated' import {render as HTMLRender, fireEvent} from '@testing-library/react' import {axe, toHaveNoViolations} from 'jest-axe' @@ -78,7 +78,7 @@ describe('Dialog', () => { options: {skipAs: true, skipSx: true}, }) - checkExports('Dialog', { + checkExports('Dialog/Dialog', { default: Dialog, }) diff --git a/src/Dialog/Dialog.tsx b/src/Dialog/Dialog.tsx index 6a6391384af..dd8946c9144 100644 --- a/src/Dialog/Dialog.tsx +++ b/src/Dialog/Dialog.tsx @@ -1,470 +1,147 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react' +import React, {forwardRef, useRef} from 'react' import styled from 'styled-components' -import Button, {ButtonPrimary, ButtonDanger, ButtonProps} from '../deprecated/Button' -import Box from '../Box' +import {IconButton} from '../Button' +import {XIcon} from '@primer/octicons-react' import {get} from '../constants' -import {useOnEscapePress, useProvidedRefOrCreate} from '../hooks' -import {useFocusTrap} from '../hooks/useFocusTrap' +import Box from '../Box' +import useDialog from '../hooks/useDialog' import sx, {SxProp} from '../sx' -import Octicon from '../Octicon' -import {XIcon} from '@primer/octicons-react' -import {useFocusZone} from '../hooks/useFocusZone' -import {FocusKeys} from '@primer/behaviors' -import Portal from '../Portal' +import Text from '../Text' +import {ComponentProps} from '../utils/types' import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef' -import {useId} from '../hooks/useId' - -const ANIMATION_DURATION = '200ms' - -/** - * Props that characterize a button to be rendered into the footer of - * a Dialog. - */ -export type DialogButtonProps = ButtonProps & { - /** - * The type of Button element to use - */ - buttonType?: 'normal' | 'primary' | 'danger' - - /** - * The Button's inner text - */ - content: React.ReactNode - - /** - * If true, and if this is the only button with autoFocus set to true, - * focus this button automatically when the dialog appears. - */ - autoFocus?: boolean - - /** - * A reference to the rendered Button’s DOM node, used together with - * `autoFocus` for `focusTrap`’s `initialFocus`. - */ - ref?: React.RefObject -} - -/** - * Props to customize the rendering of the Dialog. - */ -export interface DialogProps extends SxProp { - /** - * Title of the Dialog. Also serves as the aria-label for this Dialog. - */ - title?: React.ReactNode - - /** - * The Dialog's subtitle. Optional. Rendered below the title in smaller - * type with less contrast. Also serves as the aria-describedby for this - * Dialog. - */ - subtitle?: React.ReactNode - - /** - * Provide a custom renderer for the dialog header. This content is - * rendered directly into the dialog body area, full bleed from edge - * to edge, top to the start of the body element. - * - * Warning: using a custom renderer may violate Primer UX principles. - */ - renderHeader?: React.FunctionComponent> - - /** - * Provide a custom render function for the dialog body. This content is - * rendered directly into the dialog body area, full bleed from edge to - * edge, header to footer. - * - * Warning: using a custom renderer may violate Primer UX principles. - */ - renderBody?: React.FunctionComponent> - - /** - * Provide a custom render function for the dialog footer. This content is - * rendered directly into the dialog footer area, full bleed from edge to - * edge, end of the body element to bottom. - * - * Warning: using a custom renderer may violate Primer UX principles. - */ - renderFooter?: React.FunctionComponent> - - /** - * Specifies the buttons to be rendered in the Dialog footer. - */ - footerButtons?: DialogButtonProps[] - - /** - * This method is invoked when a gesture to close the dialog is used (either - * an Escape key press or clicking the "X" in the top-right corner). The - * gesture argument indicates the gesture that was used to close the dialog - * (either 'close-button' or 'escape'). - */ - onClose: (gesture: 'close-button' | 'escape') => void - - /** - * Default: "dialog". The ARIA role to assign to this dialog. - * @see https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal - * @see https://www.w3.org/TR/wai-aria-practices-1.1/#alertdialog - */ - role?: 'dialog' | 'alertdialog' - - /** - * The width of the dialog. - * small: 296px - * medium: 320px - * large: 480px - * xlarge: 640px - */ - width?: DialogWidth - - /** - * The height of the dialog. - * small: 296x480 - * large: 480x640 - * auto: variable based on contents - */ - height?: DialogHeight -} -/** - * Props that are passed to a component that serves as a dialog header - */ -export interface DialogHeaderProps extends DialogProps { - /** - * ID of the element that will be used as the `aria-labelledby` attribute on the - * dialog. This ID should be set to the element that renders the dialog's title. - */ - dialogLabelId: string +const noop = () => null - /** - * ID of the element that will be used as the `aria-describedby` attribute on the - * dialog. This ID should be set to the element that renders the dialog's subtitle. - */ - dialogDescriptionId: string -} +type StyledDialogBaseProps = { + narrow?: boolean + wide?: boolean +} & SxProp -const Backdrop = styled('div')` +const DialogBase = styled.div` + box-shadow: ${get('shadows.shadow.large')}; + border-radius: ${get('radii.2')}; position: fixed; top: 0; - left: 0; - bottom: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - background-color: ${get('colors.primer.canvas.backdrop')}; - animation: dialog-backdrop-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')}; - - @keyframes dialog-backdrop-appear { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } + left: 50%; + transform: translateX(-50%); + max-height: 80vh; + z-index: 999; + margin: 10vh auto; + background-color: ${get('colors.canvas.default')}; + width: ${props => (props.narrow ? '320px' : props.wide ? '640px' : '440px')}; + outline: none; + + @media screen and (max-width: 750px) { + width: 100vw; + margin: 0; + border-radius: 0; + height: 100vh; } -` - -const heightMap = { - small: '480px', - large: '640px', - auto: 'auto', -} as const - -const widthMap = { - small: '296px', - medium: '320px', - large: '480px', - xlarge: '640px', -} as const -export type DialogWidth = keyof typeof widthMap -export type DialogHeight = keyof typeof heightMap - -type StyledDialogProps = { - width?: DialogWidth - height?: DialogHeight -} & SxProp + ${sx}; +` -const StyledDialog = styled.div` +const DialogHeaderBase = styled(Box)` + border-radius: ${get('radii.2')} ${get('radii.2')} 0px 0px; + border-bottom: 1px solid ${get('colors.border.default')}; display: flex; - flex-direction: column; - background-color: ${get('colors.canvas.overlay')}; - box-shadow: ${get('shadows.overlay.shadow')}; - min-width: 296px; - max-width: calc(100vw - 64px); - max-height: calc(100vh - 64px); - width: ${props => widthMap[props.width ?? ('xlarge' as const)]}; - height: ${props => heightMap[props.height ?? ('auto' as const)]}; - border-radius: 12px; - opacity: 1; - animation: overlay--dialog-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')}; - @keyframes overlay--dialog-appear { - 0% { - opacity: 0; - transform: scale(0.5); - } - 100% { - opacity: 1; - transform: scale(1); - } + @media screen and (max-width: 750px) { + border-radius: 0px; } ${sx}; ` +export type DialogHeaderProps = ComponentProps + +function DialogHeader({theme, children, backgroundColor = 'canvas.subtle', ...rest}: DialogHeaderProps) { + if (React.Children.toArray(children).every(ch => typeof ch === 'string')) { + children = ( + + {children} + + ) + } -const DefaultHeader: React.FC> = ({ - dialogLabelId, - title, - subtitle, - dialogDescriptionId, - onClose, -}) => { - const onCloseClick = useCallback(() => { - onClose('close-button') - }, [onClose]) return ( - - - - {title ?? 'Dialog'} - {subtitle && {subtitle}} - - - - + + {children} + ) } -const DefaultBody: React.FC> = ({children}) => { - return {children} -} -const DefaultFooter: React.FC> = ({footerButtons}) => { - const {containerRef: footerRef} = useFocusZone({ - bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.Tab, - focusInStrategy: 'closest', - }) - return footerButtons ? ( - }> - - - ) : null -} -const _Dialog = React.forwardRef>((props, forwardedRef) => { - const { - title = 'Dialog', - subtitle = '', - renderHeader, - renderBody, - renderFooter, - onClose, - role = 'dialog', - width = 'xlarge', - height = 'auto', - footerButtons = [], - sx, - } = props - const dialogLabelId = useId() - const dialogDescriptionId = useId() - const autoFocusedFooterButtonRef = useRef(null) - for (const footerButton of footerButtons) { - if (footerButton.autoFocus) { - footerButton.ref = autoFocusedFooterButtonRef - } +const Overlay = styled.span` + &:before { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: block; + cursor: default; + content: ' '; + background: transparent; + z-index: 99; + background: ${get('colors.primer.canvas.backdrop')}; } - const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId} - - const dialogRef = useRef(null) - useRefObjectAsForwardedRef(forwardedRef, dialogRef) - const backdropRef = useRef(null) - useFocusTrap({containerRef: dialogRef, restoreFocusOnCleanUp: true, initialFocusRef: autoFocusedFooterButtonRef}) - - useOnEscapePress( - (event: KeyboardEvent) => { - onClose('escape') - event.preventDefault() - }, - [onClose], - ) - - React.useEffect(() => { - const bodyOverflowStyle = document.body.style.overflow || '' - // If the body is already set to overflow: hidden, it likely means - // that there is already a modal open. In that case, we should bail - // so we don't re-enable scroll after the second dialog is closed. - if (bodyOverflowStyle === 'hidden') { - return - } - - document.body.style.overflow = 'hidden' - - return () => { - document.body.style.overflow = bodyOverflowStyle - } - }, []) - - const header = (renderHeader ?? DefaultHeader)(defaultedProps) - const body = (renderBody ?? DefaultBody)(defaultedProps) - const footer = (renderFooter ?? DefaultFooter)(defaultedProps) - - return ( - <> - - - - {header} - {body} - {footer} - - - - - ) -}) -_Dialog.displayName = 'Dialog' - -const Header = styled.div` - box-shadow: 0 1px 0 ${get('colors.border.default')}; - padding: ${get('space.2')}; - z-index: 1; - flex-shrink: 0; -` - -const Title = styled.h1` - font-size: ${get('fontSizes.1')}; - font-weight: ${get('fontWeights.bold')}; - margin: 0; /* override default margin */ - ${sx}; -` - -const Subtitle = styled.h2` - font-size: ${get('fontSizes.0')}; - color: ${get('colors.fg.muted')}; - margin: 0; /* override default margin */ - margin-top: ${get('space.1')}; - - ${sx}; ` -const Body = styled.div` - flex-grow: 1; - overflow: auto; - padding: ${get('space.3')}; - - ${sx}; -` - -const Footer = styled.div` - box-shadow: 0 -1px 0 ${get('colors.border.default')}; - padding: ${get('space.3')}; - display: flex; - flex-flow: wrap; - justify-content: flex-end; - z-index: 1; - flex-shrink: 0; - - button { - margin-left: ${get('space.1')}; - &:first-child { - margin-left: 0; +type InternalDialogProps = { + isOpen?: boolean + onDismiss?: () => void + initialFocusRef?: React.RefObject + returnFocusRef?: React.RefObject +} & ComponentProps + +const Dialog = forwardRef( + ({children, onDismiss = noop, isOpen, initialFocusRef, returnFocusRef, ...props}, forwardedRef) => { + const overlayRef = useRef(null) + const modalRef = useRef(null) + useRefObjectAsForwardedRef(forwardedRef, modalRef) + const closeButtonRef = useRef(null) + + const onCloseClick = () => { + onDismiss() + if (returnFocusRef && returnFocusRef.current) { + returnFocusRef.current.focus() + } } - } - - ${sx}; -` -const buttonTypes = { - normal: Button, - primary: ButtonPrimary, - danger: ButtonDanger, + const {getDialogProps} = useDialog({ + modalRef, + onDismiss: onCloseClick, + isOpen, + initialFocusRef, + closeButtonRef, + returnFocusRef, + overlayRef, + }) + return isOpen ? ( + <> + + + + {children} + + + ) : null + }, +) + +DialogHeader.propTypes = { + ...Box.propTypes, } -const Buttons: React.FC> = ({buttons}) => { - const autoFocusRef = useProvidedRefOrCreate(buttons.find(button => button.autoFocus)?.ref) - let autoFocusCount = 0 - const [hasRendered, setHasRendered] = useState(0) - useEffect(() => { - // hack to work around dialogs originating from other focus traps. - if (hasRendered === 1) { - autoFocusRef.current?.focus() - } else { - setHasRendered(hasRendered + 1) - } - }, [autoFocusRef, hasRendered]) - return ( - <> - {buttons.map((dialogButtonProps, index) => { - const {content, buttonType = 'normal', autoFocus = false, ...buttonProps} = dialogButtonProps - const ButtonElement = buttonTypes[buttonType] - return ( - - {content} - - ) - })} - - ) -} -const DialogCloseButton = styled(Button)` - border-radius: 4px; - background: transparent; - border: 0; - vertical-align: middle; - color: ${get('colors.fg.muted')}; - padding: ${get('space.2')}; - align-self: flex-start; - line-height: normal; - box-shadow: none; -` -const CloseButton: React.FC void}>> = ({onClose}) => { - return ( - - - - ) -} +DialogHeader.displayName = 'Dialog.Header' +Dialog.displayName = 'Dialog' + +export type DialogProps = ComponentProps -/** - * A dialog is a type of overlay that can be used for confirming actions, asking - * for disambiguation, and presenting small forms. They generally allow the user - * to focus on a quick task without having to navigate to a different page. - * - * Dialogs appear in the page after a direct user interaction. Don't show dialogs - * on page load or as system alerts. - * - * Dialogs appear centered in the page, with a visible backdrop that dims the rest - * of the window for focus. - * - * All dialogs have a title and a close button. - * - * Dialogs are modal. Dialogs can be dismissed by clicking on the close button, - * pressing the escape key, or by interacting with another button in the dialog. - * To avoid losing information and missing important messages, clicking outside - * of the dialog will not close it. - * - * The sub components provided (e.g. Header, Title, etc.) are available for custom - * renderers only. They are not intended to be used otherwise. - */ -export const Dialog = Object.assign(_Dialog, { - Header, - Title, - Subtitle, - Body, - Footer, - Buttons, - CloseButton, -}) +export default Object.assign(Dialog, {Header: DialogHeader}) diff --git a/src/__tests__/Dialog.types.test.tsx b/src/Dialog/Dialog.types.test.tsx similarity index 86% rename from src/__tests__/Dialog.types.test.tsx rename to src/Dialog/Dialog.types.test.tsx index de6b44c65ea..b6578c4b40a 100644 --- a/src/__tests__/Dialog.types.test.tsx +++ b/src/Dialog/Dialog.types.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import Dialog from '../Dialog' +import Dialog from '../Dialog/Dialog' export function shouldAcceptCallWithNoProps() { return diff --git a/src/__tests__/__snapshots__/Dialog.test.tsx.snap b/src/Dialog/__snapshots__/Dialog.test.tsx.snap similarity index 100% rename from src/__tests__/__snapshots__/Dialog.test.tsx.snap rename to src/Dialog/__snapshots__/Dialog.test.tsx.snap diff --git a/src/NavList/__snapshots__/NavList.test.tsx.snap b/src/NavList/__snapshots__/NavList.test.tsx.snap index 51c4560234b..947b5e11074 100644 --- a/src/NavList/__snapshots__/NavList.test.tsx.snap +++ b/src/NavList/__snapshots__/NavList.test.tsx.snap @@ -844,12 +844,6 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav color: #1F2328; } -.c8 { - -webkit-transform: rotate(180deg); - -ms-transform: rotate(180deg); - transform: rotate(180deg); -} - .c0 { margin: 0; padding-inline-start: 0; @@ -1100,6 +1094,12 @@ exports[`NavList.Item with NavList.SubNav does not have active styles if SubNav text-decoration: none; } +.c8 { + -webkit-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg); +} + @media (hover:hover) and (pointer:fine) { .c10:hover:not([aria-disabled]) { background-color: rgba(208,215,222,0.32); @@ -1300,12 +1300,6 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t color: #1F2328; } -.c8 { - -webkit-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); -} - .c0 { margin: 0; padding-inline-start: 0; @@ -1568,6 +1562,12 @@ exports[`NavList.Item with NavList.SubNav has active styles if SubNav contains t text-decoration: none; } +.c8 { + -webkit-transform: rotate(0deg); + -ms-transform: rotate(0deg); + transform: rotate(0deg); +} + @media (hover:hover) and (pointer:fine) { .c10:hover:not([aria-disabled]) { background-color: rgba(208,215,222,0.32); diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx index 49cd7295166..516f4f2fb1c 100644 --- a/src/TreeView/TreeView.tsx +++ b/src/TreeView/TreeView.tsx @@ -7,7 +7,7 @@ import { import clsx from 'clsx' import React, {useCallback, useEffect} from 'react' import styled, {keyframes} from 'styled-components' -import {ConfirmationDialog} from '../Dialog/ConfirmationDialog' +import {ConfirmationDialog} from '../drafts/Dialog2/ConfirmationDialog' import Spinner from '../Spinner' import Text from '../Text' import VisuallyHidden from '../_VisuallyHidden' diff --git a/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap b/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap index c01e1a5cfaf..359244ccee2 100644 --- a/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap +++ b/src/__tests__/__snapshots__/AnchoredOverlay.test.tsx.snap @@ -100,6 +100,25 @@ exports[`AnchoredOverlay should render consistently when open 1`] = ` color: #1F2328; } +.c2 { + background-color: #ffffff; + box-shadow: 0 1px 3px rgba(31,35,40,0.12),0 8px 24px rgba(66,74,83,0.12); + position: absolute; + min-width: 192px; + max-width: 640px; + height: auto; + width: auto; + border-radius: 12px; + overflow: hidden; + -webkit-animation: overlay-appear 200ms cubic-bezier(0.33,1,0.68,1); + animation: overlay-appear 200ms cubic-bezier(0.33,1,0.68,1); + visibility: var(--styled-overlay-visibility); +} + +.c2:focus { + outline: none; +} + .c1 { position: relative; display: inline-block; @@ -164,25 +183,6 @@ exports[`AnchoredOverlay should render consistently when open 1`] = ` border-color: rgba(31,35,40,0.15); } -.c2 { - background-color: #ffffff; - box-shadow: 0 1px 3px rgba(31,35,40,0.12),0 8px 24px rgba(66,74,83,0.12); - position: absolute; - min-width: 192px; - max-width: 640px; - height: auto; - width: auto; - border-radius: 12px; - overflow: hidden; - -webkit-animation: overlay-appear 200ms cubic-bezier(0.33,1,0.68,1); - animation: overlay-appear 200ms cubic-bezier(0.33,1,0.68,1); - visibility: var(--styled-overlay-visibility); -} - -.c2:focus { - outline: none; -} - @media (forced-colors:active) { .c2 { outline: solid 1px transparent; diff --git a/src/__tests__/__snapshots__/ConfirmationDialog.test.tsx.snap b/src/__tests__/__snapshots__/ConfirmationDialog.test.tsx.snap deleted file mode 100644 index 1d33d06ccb4..00000000000 --- a/src/__tests__/__snapshots__/ConfirmationDialog.test.tsx.snap +++ /dev/null @@ -1,90 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ConfirmationDialog renders consistently 1`] = ` -.c0 { - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; - line-height: 1.5; - color: #1F2328; -} - -.c1 { - position: relative; - display: inline-block; - padding: 6px 16px; - font-family: inherit; - font-weight: 600; - line-height: 20px; - white-space: nowrap; - vertical-align: middle; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - border-radius: 6px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - -webkit-text-decoration: none; - text-decoration: none; - text-align: center; - font-size: 14px; - color: #24292f; - background-color: #f6f8fa; - border: 1px solid rgba(31,35,40,0.15); - box-shadow: 0 1px 0 rgba(31,35,40,0.04),inset 0 1px 0 rgba(255,255,255,0.25); -} - -.c1:hover { - -webkit-text-decoration: none; - text-decoration: none; -} - -.c1:focus { - outline: none; -} - -.c1:disabled { - cursor: default; -} - -.c1:disabled svg { - opacity: 0.6; -} - -.c1:hover { - background-color: #f3f4f6; - border-color: rgba(31,35,40,0.15); -} - -.c1:focus { - outline: solid 2px #0969da; -} - -.c1:active { - background-color: hsla(220,14%,94%,1); -} - -.c1:disabled { - color: #8c959f; - background-color: #f6f8fa; - border-color: rgba(31,35,40,0.15); -} - -
- -
-`; diff --git a/src/__tests__/__snapshots__/exports.test.ts.snap b/src/__tests__/__snapshots__/exports.test.ts.snap index 2e0cfbd48ec..765c1fadf5c 100644 --- a/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/src/__tests__/__snapshots__/exports.test.ts.snap @@ -22,7 +22,6 @@ exports[`@primer/react should not update exports without a semver change 1`] = ` "CheckboxGroup", "CircleBadge", "CircleOcticon", - "ConfirmationDialog", "CounterLabel", "Details", "Dialog", @@ -78,7 +77,6 @@ exports[`@primer/react should not update exports without a semver change 1`] = ` "theme", "themeGet", "useColorSchemeVar", - "useConfirm", "useDetails", "useFocusTrap", "useFocusZone", @@ -115,6 +113,7 @@ exports[`@primer/react/decprecated should not update exports without a semver ch exports[`@primer/react/drafts should not update exports without a semver change 1`] = ` [ "Blankslate", + "ConfirmationDialog", "DataTable", "Dialog", "Hidden", @@ -126,6 +125,7 @@ exports[`@primer/react/drafts should not update exports without a semver change "Table", "callbackCancelledResult", "useCombobox", + "useConfirm", "useDynamicTextareaHeight", "useIgnoreKeyboardActionsWhileComposing", "useSafeAsyncCallback", diff --git a/src/stories/ConfirmationDialog.stories.tsx b/src/drafts/Dialog2/ConfirmationDialog.stories.tsx similarity index 91% rename from src/stories/ConfirmationDialog.stories.tsx rename to src/drafts/Dialog2/ConfirmationDialog.stories.tsx index 6f5668a4b32..c3986e55de3 100644 --- a/src/stories/ConfirmationDialog.stories.tsx +++ b/src/drafts/Dialog2/ConfirmationDialog.stories.tsx @@ -1,10 +1,10 @@ import React, {useState, useRef, useCallback} from 'react' import {Meta} from '@storybook/react' -import {BaseStyles, Box, ThemeProvider, useTheme} from '..' -import {Button} from '../Button' -import {ActionMenu} from '../ActionMenu' -import {ActionList} from '../ActionList' -import {ConfirmationDialog, useConfirm} from '../Dialog/ConfirmationDialog' +import {BaseStyles, Box, ThemeProvider, useTheme} from '../..' +import {Button} from '../../Button' +import {ActionMenu} from '../../ActionMenu' +import {ActionList} from '../../ActionList' +import {ConfirmationDialog, useConfirm} from './ConfirmationDialog' export default { title: 'Components/ConfirmationDialog', diff --git a/src/__tests__/ConfirmationDialog.test.tsx b/src/drafts/Dialog2/ConfirmationDialog.test.tsx similarity index 62% rename from src/__tests__/ConfirmationDialog.test.tsx rename to src/drafts/Dialog2/ConfirmationDialog.test.tsx index 602acbd9110..4bdb777fbee 100644 --- a/src/__tests__/ConfirmationDialog.test.tsx +++ b/src/drafts/Dialog2/ConfirmationDialog.test.tsx @@ -2,15 +2,16 @@ import {render as HTMLRender, fireEvent} from '@testing-library/react' import {axe} from 'jest-axe' import React, {useCallback, useRef, useState} from 'react' -import {ActionMenu} from '../deprecated/ActionMenu' -import BaseStyles from '../BaseStyles' -import Box from '../Box' -import Button from '../deprecated/Button/Button' -import {ConfirmationDialog, useConfirm} from '../Dialog/ConfirmationDialog' -import theme from '../theme' -import {ThemeProvider} from '../ThemeProvider' -import {SSRProvider} from '../utils/ssr' -import {behavesAsComponent, checkExports} from '../utils/testing' +import {ActionMenu} from '../../ActionMenu' +import {ActionList} from '../../ActionList' +import BaseStyles from '../../BaseStyles' +import Box from '../../Box' +import {Button} from '../../Button' +import {ConfirmationDialog, useConfirm} from './ConfirmationDialog' +import theme from '../../theme' +import {ThemeProvider} from '../../ThemeProvider' +import {SSRProvider} from '../../utils/ssr' +import {behavesAsComponent, checkExports} from '../../utils/testing' const Basic = ({confirmButtonType}: Pick, 'confirmButtonType'>) => { const [isOpen, setIsOpen] = useState(false) @@ -60,10 +61,14 @@ const ShorthandHookFromActionMenu = () => { - } - items={[{text: 'Show dialog', onAction: onButtonClick}]} - /> + + {text} + + + Show dialog + + + @@ -78,7 +83,7 @@ describe('ConfirmationDialog', () => { options: {skipAs: true, skipSx: true}, }) - checkExports('Dialog/ConfirmationDialog', { + checkExports('drafts/Dialog2/ConfirmationDialog', { default: undefined, useConfirm, ConfirmationDialog, @@ -93,33 +98,33 @@ describe('ConfirmationDialog', () => { }) it('focuses the primary action when opened and the confirmButtonType is not set', async () => { - const {getByText} = HTMLRender() + const {getByText, getByRole} = HTMLRender() fireEvent.click(getByText('Show dialog')) - expect(getByText('Primary')).toEqual(document.activeElement) - expect(getByText('Secondary')).not.toEqual(document.activeElement) + expect(getByRole('button', {name: 'Primary'})).toHaveFocus() + expect(getByRole('button', {name: 'Secondary'})).not.toHaveFocus() }) it('focuses the primary action when opened and the confirmButtonType is not danger', async () => { - const {getByText} = HTMLRender() + const {getByText, getByRole} = HTMLRender() fireEvent.click(getByText('Show dialog')) - expect(getByText('Primary')).toEqual(document.activeElement) - expect(getByText('Secondary')).not.toEqual(document.activeElement) + expect(getByRole('button', {name: 'Primary'})).toHaveFocus() + expect(getByRole('button', {name: 'Secondary'})).not.toHaveFocus() }) it('focuses the secondary action when opened and the confirmButtonType is danger', async () => { - const {getByText} = HTMLRender() + const {getByText, getByRole} = HTMLRender() fireEvent.click(getByText('Show dialog')) - expect(getByText('Primary')).not.toEqual(document.activeElement) - expect(getByText('Secondary')).toEqual(document.activeElement) + expect(getByRole('button', {name: 'Primary'})).not.toHaveFocus() + expect(getByRole('button', {name: 'Secondary'})).toHaveFocus() }) it('supports nested `focusTrap`s', async () => { - const {getByText} = HTMLRender() + const {getByText, getByRole} = HTMLRender() fireEvent.click(getByText('Show menu')) fireEvent.click(getByText('Show dialog')) - expect(getByText('Primary')).toEqual(document.activeElement) - expect(getByText('Secondary')).not.toEqual(document.activeElement) + expect(getByRole('button', {name: 'Primary'})).toHaveFocus() + expect(getByRole('button', {name: 'Secondary'})).not.toHaveFocus() }) }) diff --git a/src/Dialog/ConfirmationDialog.tsx b/src/drafts/Dialog2/ConfirmationDialog.tsx similarity index 96% rename from src/Dialog/ConfirmationDialog.tsx rename to src/drafts/Dialog2/ConfirmationDialog.tsx index d659ab23e1c..3faccb77c31 100644 --- a/src/Dialog/ConfirmationDialog.tsx +++ b/src/drafts/Dialog2/ConfirmationDialog.tsx @@ -1,12 +1,12 @@ import React, {useCallback} from 'react' import {createRoot} from 'react-dom/client' import styled from 'styled-components' -import Box from '../Box' -import {ThemeProvider, useTheme, ThemeProviderProps} from '../ThemeProvider' +import Box from '../../Box' +import {ThemeProvider, useTheme, ThemeProviderProps} from '../../ThemeProvider' import {FocusKeys} from '@primer/behaviors' -import {get} from '../constants' -import {Dialog, DialogProps, DialogHeaderProps, DialogButtonProps} from '../Dialog/Dialog' -import {useFocusZone} from '../hooks/useFocusZone' +import {get} from '../../constants' +import {Dialog, DialogProps, DialogHeaderProps, DialogButtonProps} from './Dialog' +import {useFocusZone} from '../../hooks/useFocusZone' /** * Props to customize the ConfirmationDialog. diff --git a/src/drafts/Dialog2/Dialog.docs.json b/src/drafts/Dialog2/Dialog.docs.json new file mode 100644 index 00000000000..7dd05d7a60d --- /dev/null +++ b/src/drafts/Dialog2/Dialog.docs.json @@ -0,0 +1,58 @@ +{ + "id": "drafts_dialog", + "name": "Dialog", + "status": "draft", + "a11yReviewed": false, + "stories": [], + "props": [ + { + "name": "title", + "type": "React.ReactNode", + "description": "Title of the Dialog. Also serves as the aria-label for this Dialog." + }, + { + "name": "subtitle", + "type": "React.ReactNode", + "description": "The Dialog's subtitle. Optional. Rendered below the title in smaller type with less contrast. Also serves as the aria-describedby for this Dialog." + }, + { + "name": "renderHeader", + "type": "React.FunctionComponent<\n React.PropsWithChildren\n>", + "description": "Provide a custom renderer for the dialog header. This content is rendered directly into the dialog body area, full bleed from edge to edge, top to the start of the body element. Warning: using a custom renderer may violate Primer UX principles." + }, + { + "name": "renderBody", + "type": "React.FunctionComponent<\n React.PropsWithChildren\n>", + "description": "Provide a custom render function for the dialog body. This content is rendered directly into the dialog body area, full bleed from edge to edge, header to footer. Warning: using a custom renderer may violate Primer UX principles." + }, + { + "name": "renderFooter", + "type": "React.FunctionComponent<\n React.PropsWithChildren\n>", + "description": "Provide a custom render function for the dialog footer. This content is rendered directly into the dialog footer area, full bleed from edge to edge, end of the body element to bottom. Warning: using a custom renderer may violate Primer UX principles." + }, + { + "name": "footerButtons", + "type": "DialogButtonProps[]", + "description": "Specifies the buttons to be rendered in the Dialog footer." + }, + { + "name": "onClose", + "type": "(gesture: 'close-button' | 'escape') => void", + "description": "This method is invoked when a gesture to close the dialog is used (either an Escape key press or clicking the 'X' in the top-right corner). The gesture argument indicates the gesture that was used to close the dialog (either 'close-button' or 'escape')." + }, + { + "name": "role", + "type": "'dialog'\n| 'alertdialog'", + "description": "The ARIA role to assign to this dialog." + }, + { + "name": "width", + "type": "'small'\n| 'medium'\n| 'large'\n| 'xlarge'" + }, + { + "name": "height", + "type": "'small'\n| 'large'\n| 'auto'" + } + ], + "subcomponents": [] +} diff --git a/src/stories/Dialog.stories.tsx b/src/drafts/Dialog2/Dialog.stories.tsx similarity index 97% rename from src/stories/Dialog.stories.tsx rename to src/drafts/Dialog2/Dialog.stories.tsx index 95cabc04649..0bc128af2e7 100644 --- a/src/stories/Dialog.stories.tsx +++ b/src/drafts/Dialog2/Dialog.stories.tsx @@ -1,12 +1,13 @@ import React, {useState, useRef, useCallback} from 'react' import {Meta} from '@storybook/react' -import {BaseStyles, ThemeProvider, Box, TextInput} from '..' -import {Button} from '../Button' -import {Dialog, DialogProps, DialogWidth, DialogHeight} from '../Dialog/Dialog' +import {BaseStyles, ThemeProvider, Box, TextInput} from '../..' +import {Button} from '../../Button' +import {Dialog, DialogProps, DialogWidth, DialogHeight} from './Dialog' +// TODO: update this title and all other draft component titles to "Drafts/{component name}" export default { - title: 'Components/Dialog', + title: 'Drafts/Components/Dialog', component: Dialog, decorators: [ Story => { diff --git a/src/drafts/Dialog2/Dialog.tsx b/src/drafts/Dialog2/Dialog.tsx new file mode 100644 index 00000000000..c952bb8a071 --- /dev/null +++ b/src/drafts/Dialog2/Dialog.tsx @@ -0,0 +1,438 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import styled from 'styled-components' +import {Box, Button, ButtonProps, IconButton, Portal, Text} from '../..' +import {get} from '../../constants' +import {useOnEscapePress, useProvidedRefOrCreate} from '../../hooks' +import {useFocusTrap} from '../../hooks/useFocusTrap' +import sx, {SxProp} from '../../sx' +import {XIcon} from '@primer/octicons-react' +import {useFocusZone} from '../../hooks/useFocusZone' +import {FocusKeys} from '@primer/behaviors' +import {useRefObjectAsForwardedRef} from '../../hooks/useRefObjectAsForwardedRef' +import {useId} from '../../hooks/useId' + +const ANIMATION_DURATION = '200ms' + +/** + * Props that characterize a button to be rendered into the footer of + * a Dialog. + */ +export type DialogButtonProps = Omit & { + /** + * The type of Button element to use + */ + buttonType?: 'default' | 'primary' | 'danger' | 'normal' + + /** + * The Button's inner text + */ + content: React.ReactNode + + /** + * If true, and if this is the only button with autoFocus set to true, + * focus this button automatically when the dialog appears. + */ + autoFocus?: boolean + + /** + * A reference to the rendered Button’s DOM node, used together with + * `autoFocus` for `focusTrap`’s `initialFocus`. + */ + ref?: React.RefObject +} + +/** + * Props to customize the rendering of the Dialog. + */ +export interface DialogProps extends SxProp { + /** + * Title of the Dialog. Also serves as the aria-label for this Dialog. + */ + title?: React.ReactNode + + /** + * The Dialog's subtitle. Optional. Rendered below the title in smaller + * type with less contrast. Also serves as the aria-describedby for this + * Dialog. + */ + subtitle?: React.ReactNode + + /** + * Provide a custom renderer for the dialog header. This content is + * rendered directly into the dialog body area, full bleed from edge + * to edge, top to the start of the body element. + * + * Warning: using a custom renderer may violate Primer UX principles. + */ + renderHeader?: React.FunctionComponent> + + /** + * Provide a custom render function for the dialog body. This content is + * rendered directly into the dialog body area, full bleed from edge to + * edge, header to footer. + * + * Warning: using a custom renderer may violate Primer UX principles. + */ + renderBody?: React.FunctionComponent> + + /** + * Provide a custom render function for the dialog footer. This content is + * rendered directly into the dialog footer area, full bleed from edge to + * edge, end of the body element to bottom. + * + * Warning: using a custom renderer may violate Primer UX principles. + */ + renderFooter?: React.FunctionComponent> + + /** + * Specifies the buttons to be rendered in the Dialog footer. + */ + footerButtons?: DialogButtonProps[] + + /** + * This method is invoked when a gesture to close the dialog is used (either + * an Escape key press or clicking the "X" in the top-right corner). The + * gesture argument indicates the gesture that was used to close the dialog + * (either 'close-button' or 'escape'). + */ + onClose: (gesture: 'close-button' | 'escape') => void + + /** + * Default: "dialog". The ARIA role to assign to this dialog. + * @see https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal + * @see https://www.w3.org/TR/wai-aria-practices-1.1/#alertdialog + */ + role?: 'dialog' | 'alertdialog' + + /** + * The width of the dialog. + * small: 296px + * medium: 320px + * large: 480px + * xlarge: 640px + */ + width?: DialogWidth + + /** + * The height of the dialog. + * small: 296x480 + * large: 480x640 + * auto: variable based on contents + */ + height?: DialogHeight +} + +/** + * Props that are passed to a component that serves as a dialog header + */ +export interface DialogHeaderProps extends DialogProps { + /** + * ID of the element that will be used as the `aria-labelledby` attribute on the + * dialog. This ID should be set to the element that renders the dialog's title. + */ + dialogLabelId: string + + /** + * ID of the element that will be used as the `aria-describedby` attribute on the + * dialog. This ID should be set to the element that renders the dialog's subtitle. + */ + dialogDescriptionId: string +} + +const Backdrop = styled('div')` + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: ${get('colors.primer.canvas.backdrop')}; + animation: dialog-backdrop-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')}; + + @keyframes dialog-backdrop-appear { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } +` + +const heightMap = { + small: '480px', + large: '640px', + auto: 'auto', +} as const + +const widthMap = { + small: '296px', + medium: '320px', + large: '480px', + xlarge: '640px', +} as const + +export type DialogWidth = keyof typeof widthMap +export type DialogHeight = keyof typeof heightMap + +type StyledDialogProps = { + width?: DialogWidth + height?: DialogHeight +} & SxProp + +const StyledDialog = styled.div` + display: flex; + flex-direction: column; + background-color: ${get('colors.canvas.overlay')}; + box-shadow: ${get('shadows.overlay.shadow')}; + min-width: 296px; + max-width: calc(100vw - 64px); + max-height: calc(100vh - 64px); + width: ${props => widthMap[props.width ?? ('xlarge' as const)]}; + height: ${props => heightMap[props.height ?? ('auto' as const)]}; + border-radius: 12px; + opacity: 1; + animation: overlay--dialog-appear ${ANIMATION_DURATION} ${get('animation.easeOutCubic')}; + + @keyframes overlay--dialog-appear { + 0% { + opacity: 0; + transform: scale(0.5); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + ${sx}; +` + +const DefaultHeader: React.FC> = ({ + dialogLabelId, + title, + subtitle, + dialogDescriptionId, + onClose, +}) => { + const onCloseClick = useCallback(() => { + onClose('close-button') + }, [onClose]) + return ( + + + + {title ?? 'Dialog'} + {subtitle && {subtitle}} + + + + + ) +} +const DefaultBody: React.FC> = ({children}) => { + return {children} +} +const DefaultFooter: React.FC> = ({footerButtons}) => { + const {containerRef: footerRef} = useFocusZone({ + bindKeys: FocusKeys.ArrowHorizontal | FocusKeys.Tab, + focusInStrategy: 'closest', + }) + return footerButtons ? ( + }> + + + ) : null +} + +const _Dialog = React.forwardRef>((props, forwardedRef) => { + const { + title = 'Dialog', + subtitle = '', + renderHeader, + renderBody, + renderFooter, + onClose, + role = 'dialog', + width = 'xlarge', + height = 'auto', + footerButtons = [], + sx, + } = props + const dialogLabelId = useId() + const dialogDescriptionId = useId() + const autoFocusedFooterButtonRef = useRef(null) + for (const footerButton of footerButtons) { + if (footerButton.autoFocus) { + footerButton.ref = autoFocusedFooterButtonRef + } + } + const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId} + + const dialogRef = useRef(null) + useRefObjectAsForwardedRef(forwardedRef, dialogRef) + const backdropRef = useRef(null) + useFocusTrap({containerRef: dialogRef, restoreFocusOnCleanUp: true, initialFocusRef: autoFocusedFooterButtonRef}) + + useOnEscapePress( + (event: KeyboardEvent) => { + onClose('escape') + event.preventDefault() + }, + [onClose], + ) + + React.useEffect(() => { + const bodyOverflowStyle = document.body.style.overflow || '' + // If the body is already set to overflow: hidden, it likely means + // that there is already a modal open. In that case, we should bail + // so we don't re-enable scroll after the second dialog is closed. + if (bodyOverflowStyle === 'hidden') { + return + } + + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = bodyOverflowStyle + } + }, []) + + const header = (renderHeader ?? DefaultHeader)(defaultedProps) + const body = (renderBody ?? DefaultBody)(defaultedProps) + const footer = (renderFooter ?? DefaultFooter)(defaultedProps) + + return ( + + + + {header} + {body} + {footer} + + + + ) +}) +_Dialog.displayName = 'Dialog' + +const Header = styled.div` + box-shadow: 0 1px 0 ${get('colors.border.default')}; + padding: ${get('space.2')}; + z-index: 1; + flex-shrink: 0; +` + +// TODO: check if this is an appropriate use of `h1` +const Title: React.FC & SxProp>> = ({sx, ...props}) => ( + +) + +// TODO: check if this is an appropriate use of `h2` +const Subtitle: React.FC & SxProp>> = ({sx, ...props}) => ( + +) + +const Body = styled.div` + flex-grow: 1; + overflow: auto; + padding: ${get('space.3')}; + + ${sx}; +` + +const Footer = styled.div` + box-shadow: 0 -1px 0 ${get('colors.border.default')}; + padding: ${get('space.3')}; + display: flex; + flex-flow: wrap; + justify-content: flex-end; + z-index: 1; + flex-shrink: 0; + + button { + margin-left: ${get('space.1')}; + &:first-child { + margin-left: 0; + } + } + + ${sx}; +` + +const Buttons: React.FC> = ({buttons}) => { + const autoFocusRef = useProvidedRefOrCreate(buttons.find(button => button.autoFocus)?.ref) + let autoFocusCount = 0 + const [hasRendered, setHasRendered] = useState(0) + useEffect(() => { + // hack to work around dialogs originating from other focus traps. + if (hasRendered === 1) { + autoFocusRef.current?.focus() + } else { + setHasRendered(hasRendered + 1) + } + }, [autoFocusRef, hasRendered]) + + return ( + <> + {buttons.map((dialogButtonProps, index) => { + const {content, buttonType = 'normal', autoFocus = false, ...buttonProps} = dialogButtonProps + return ( + + ) + })} + + ) +} +const CloseButton: React.FC void}>> = ({onClose}) => ( + +) + +/** + * A dialog is a type of overlay that can be used for confirming actions, asking + * for disambiguation, and presenting small forms. They generally allow the user + * to focus on a quick task without having to navigate to a different page. + * + * Dialogs appear in the page after a direct user interaction. Don't show dialogs + * on page load or as system alerts. + * + * Dialogs appear centered in the page, with a visible backdrop that dims the rest + * of the window for focus. + * + * All dialogs have a title and a close button. + * + * Dialogs are modal. Dialogs can be dismissed by clicking on the close button, + * pressing the escape key, or by interacting with another button in the dialog. + * To avoid losing information and missing important messages, clicking outside + * of the dialog will not close it. + * + * The sub components provided (e.g. Header, Title, etc.) are available for custom + * renderers only. They are not intended to be used otherwise. + */ +export const Dialog = Object.assign(_Dialog, { + Header, + Title, + Subtitle, + Body, + Footer, + Buttons, + CloseButton, +}) diff --git a/src/__tests__/Dialog2.types.test.tsx b/src/drafts/Dialog2/Dialog.types.test.tsx similarity index 87% rename from src/__tests__/Dialog2.types.test.tsx rename to src/drafts/Dialog2/Dialog.types.test.tsx index a5774427458..452c211b9ca 100644 --- a/src/__tests__/Dialog2.types.test.tsx +++ b/src/drafts/Dialog2/Dialog.types.test.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {Dialog} from '../Dialog/Dialog' +import {Dialog} from './Dialog' export function shouldAcceptCallWithNoProps() { return null} /> diff --git a/src/drafts/Dialog2/__snapshots__/ConfirmationDialog.test.tsx.snap b/src/drafts/Dialog2/__snapshots__/ConfirmationDialog.test.tsx.snap new file mode 100644 index 00000000000..b9f98e2f977 --- /dev/null +++ b/src/drafts/Dialog2/__snapshots__/ConfirmationDialog.test.tsx.snap @@ -0,0 +1,266 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConfirmationDialog renders consistently 1`] = ` +.c0 { + font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; + line-height: 1.5; + color: #1F2328; +} + +.c2 { + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; +} + +.c1 { + border-radius: 6px; + border: 1px solid; + border-color: rgba(31,35,40,0.15); + font-family: inherit; + font-weight: 500; + font-size: 14px; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-text-decoration: none; + text-decoration: none; + text-align: center; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + height: 32px; + padding: 0 12px; + gap: 8px; + min-width: -webkit-max-content; + min-width: -moz-max-content; + min-width: max-content; + -webkit-transition: 80ms cubic-bezier(0.65,0,0.35,1); + transition: 80ms cubic-bezier(0.65,0,0.35,1); + -webkit-transition-property: color,fill,background-color,border-color; + transition-property: color,fill,background-color,border-color; + color: #24292f; + background-color: #f6f8fa; + box-shadow: 0 1px 0 rgba(31,35,40,0.04),inset 0 1px 0 rgba(255,255,255,0.25); +} + +.c1:focus:not(:disabled) { + box-shadow: none; + outline: 2px solid #0969da; + outline-offset: -2px; +} + +.c1:focus:not(:disabled):not(:focus-visible) { + outline: solid 1px transparent; +} + +.c1:focus-visible:not(:disabled) { + box-shadow: none; + outline: 2px solid #0969da; + outline-offset: -2px; +} + +.c1[href] { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; +} + +.c1[href]:hover { + -webkit-text-decoration: none; + text-decoration: none; +} + +.c1:hover { + -webkit-transition-duration: 80ms; + transition-duration: 80ms; +} + +.c1:active { + -webkit-transition: none; + transition: none; +} + +.c1:disabled { + cursor: not-allowed; + box-shadow: none; + color: #8c959f; +} + +.c1:disabled [data-component=ButtonCounter] { + color: inherit; +} + +.c1 [data-component=ButtonCounter] { + font-size: 12px; +} + +.c1[data-component=IconButton] { + display: inline-grid; + padding: unset; + place-content: center; + width: 32px; + min-width: unset; +} + +.c1[data-size="small"] { + padding: 0 8px; + height: 28px; + gap: 4px; + font-size: 12px; +} + +.c1[data-size="small"] [data-component="text"] { + line-height: calc(20 / 12); +} + +.c1[data-size="small"] [data-component=ButtonCounter] { + font-size: 12px; +} + +.c1[data-size="small"] [data-component="buttonContent"] > :not(:last-child) { + margin-right: 4px; +} + +.c1[data-size="small"][data-component=IconButton] { + width: 28px; + padding: unset; +} + +.c1[data-size="large"] { + padding: 0 16px; + height: 40px; + gap: 8px; +} + +.c1[data-size="large"] [data-component="buttonContent"] > :not(:last-child) { + margin-right: 8px; +} + +.c1[data-size="large"][data-component=IconButton] { + width: 40px; + padding: unset; +} + +.c1[data-block="block"] { + width: 100%; +} + +.c1 [data-component="leadingVisual"] { + grid-area: leadingVisual; +} + +.c1 [data-component="text"] { + grid-area: text; + line-height: calc(20/14); + white-space: nowrap; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.c1 [data-component="trailingVisual"] { + grid-area: trailingVisual; +} + +.c1 [data-component="trailingAction"] { + margin-right: -4px; +} + +.c1 [data-component="buttonContent"] { + -webkit-flex: 1 0 auto; + -ms-flex: 1 0 auto; + flex: 1 0 auto; + display: grid; + grid-template-areas: "leadingVisual text trailingVisual"; + grid-template-columns: min-content minmax(0,auto) min-content; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-align-content: center; + -ms-flex-line-pack: center; + align-content: center; +} + +.c1 [data-component="buttonContent"] > :not(:last-child) { + margin-right: 8px; +} + +.c1:hover:not([disabled]) { + background-color: #f3f4f6; + border-color: rgba(31,35,40,0.15); +} + +.c1:active:not([disabled]) { + background-color: hsla(220,14%,93%,1); + border-color: rgba(31,35,40,0.15); +} + +.c1[aria-expanded=true] { + background-color: hsla(220,14%,93%,1); + border-color: rgba(31,35,40,0.15); +} + +.c1 [data-component="leadingVisual"], +.c1 [data-component="trailingVisual"], +.c1 [data-component="trailingAction"] { + color: #656d76; +} + +@media (forced-colors:active) { + .c1:focus { + outline: solid 1px transparent; + } +} + +
+ +
+`; diff --git a/src/drafts/index.ts b/src/drafts/index.ts index db6d1fa0beb..f9577bcf26d 100644 --- a/src/drafts/index.ts +++ b/src/drafts/index.ts @@ -24,7 +24,10 @@ export type { TableActionsProps, } from '../DataTable' -export * from '../Dialog/Dialog' +export * from './Dialog2/Dialog' +export type {ConfirmationDialogProps} from './Dialog2/ConfirmationDialog' +export {ConfirmationDialog} from './Dialog2/ConfirmationDialog' +export {useConfirm} from './Dialog2/ConfirmationDialog' export {default as InlineAutocomplete} from './InlineAutocomplete' export type { diff --git a/src/index.ts b/src/index.ts index 5a03fa04511..86e324669b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,6 @@ export type {TouchOrMouseEvent} from './hooks/useOnOutsideClick' export {useOpenAndCloseFocus} from './hooks/useOpenAndCloseFocus' export {useOnEscapePress} from './hooks/useOnEscapePress' export {useOverlay} from './hooks/useOverlay' -export {useConfirm} from './Dialog/ConfirmationDialog' export {useFocusTrap} from './hooks/useFocusTrap' export type {FocusTrapHookSettings} from './hooks/useFocusTrap' export {useFocusZone} from './hooks/useFocusZone' @@ -86,10 +85,8 @@ export {default as CounterLabel} from './CounterLabel' export type {CounterLabelProps} from './CounterLabel' export {default as Details} from './Details' export type {DetailsProps} from './Details' -export {default as Dialog} from './Dialog' -export type {DialogProps, DialogHeaderProps} from './Dialog' -export type {ConfirmationDialogProps} from './Dialog/ConfirmationDialog' -export {ConfirmationDialog} from './Dialog/ConfirmationDialog' +export {default as Dialog} from './Dialog/Dialog' +export type {DialogProps, DialogHeaderProps} from './Dialog/Dialog' export {default as Flash} from './Flash' export type {FlashProps} from './Flash' export {default as FormControl} from './FormControl'