diff --git a/packages/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx b/packages/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx index e7e3167d0b1ca..cb8b722894484 100644 --- a/packages/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx +++ b/packages/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { CloseIcon } from '../../icons/CloseIcon' export type LeftRightDialogHeaderProps = { className?: string @@ -147,34 +148,14 @@ const LeftRightDialogHeader: React.FC = {close ? ( ) : null} diff --git a/packages/react-dev-overlay/src/internal/container/Errors.tsx b/packages/react-dev-overlay/src/internal/container/Errors.tsx index 556d3bd29d994..bead04084b3dd 100644 --- a/packages/react-dev-overlay/src/internal/container/Errors.tsx +++ b/packages/react-dev-overlay/src/internal/container/Errors.tsx @@ -17,6 +17,7 @@ import { Toast } from '../components/Toast' import { getErrorByType, ReadyRuntimeError } from '../helpers/getErrorByType' import { isNodeError } from '../helpers/nodeStackFrames' import { noop as css } from '../helpers/noop-template' +import { CloseIcon } from '../icons/CloseIcon' import { RuntimeError } from './RuntimeError' export type SupportedErrorEvent = { @@ -136,7 +137,9 @@ export const Errors: React.FC = function Errors({ errors }) { } }, [nextError]) - const [isMinimized, setMinimized] = React.useState(false) + const [displayState, setDisplayState] = React.useState< + 'minimized' | 'fullscreen' | 'hidden' + >('fullscreen') const [activeIdx, setActiveIndex] = React.useState(0) const previous = React.useCallback((e?: MouseEvent | TouchEvent) => { e?.preventDefault() @@ -162,19 +165,23 @@ export const Errors: React.FC = function Errors({ errors }) { React.useEffect(() => { if (errors.length < 1) { setLookups({}) - setMinimized(false) + setDisplayState('hidden') setActiveIndex(0) } }, [errors.length]) const minimize = React.useCallback((e?: MouseEvent | TouchEvent) => { e?.preventDefault() - setMinimized(true) + setDisplayState('minimized') }, []) - const reopen = React.useCallback( + const hide = React.useCallback((e?: MouseEvent | TouchEvent) => { + e?.preventDefault() + setDisplayState('hidden') + }, []) + const fullscreen = React.useCallback( (e?: React.MouseEvent) => { e?.preventDefault() - setMinimized(false) + setDisplayState('fullscreen') }, [] ) @@ -190,9 +197,13 @@ export const Errors: React.FC = function Errors({ errors }) { return } - if (isMinimized) { + if (displayState === 'hidden') { + return null + } + + if (displayState === 'minimized') { return ( - +
= function Errors({ errors }) { {readyErrors.length} error{readyErrors.length > 1 ? 's' : ''} +
) @@ -320,4 +343,16 @@ export const styles = css` .nextjs-toast-errors > svg { margin-right: var(--size-gap); } + .nextjs-toast-errors-hide-button { + margin-left: var(--size-gap-triple); + border: none; + background: none; + color: var(--color-ansi-bright-white); + padding: 0; + transition: opacity 0.25s ease; + opacity: 0.7; + } + .nextjs-toast-errors-hide-button:hover { + opacity: 1; + } ` diff --git a/packages/react-dev-overlay/src/internal/icons/CloseIcon.tsx b/packages/react-dev-overlay/src/internal/icons/CloseIcon.tsx new file mode 100644 index 0000000000000..e5a51a44a3eb5 --- /dev/null +++ b/packages/react-dev-overlay/src/internal/icons/CloseIcon.tsx @@ -0,0 +1,30 @@ +import * as React from 'react' + +const CloseIcon = () => { + return ( + + + + + ) +} + +export { CloseIcon } diff --git a/packages/react-dev-overlay/src/internal/styles/Base.tsx b/packages/react-dev-overlay/src/internal/styles/Base.tsx index 0cc24069348fa..b48e0a25b2f9a 100644 --- a/packages/react-dev-overlay/src/internal/styles/Base.tsx +++ b/packages/react-dev-overlay/src/internal/styles/Base.tsx @@ -9,6 +9,7 @@ export function Base() { --size-gap-half: 4px; --size-gap: 8px; --size-gap-double: 16px; + --size-gap-triple: 24px; --size-gap-quad: 32px; --size-font-small: 14px; diff --git a/test/development/client-dev-overlay/app/pages/index.js b/test/development/client-dev-overlay/app/pages/index.js new file mode 100644 index 0000000000000..95d1ec6405a3c --- /dev/null +++ b/test/development/client-dev-overlay/app/pages/index.js @@ -0,0 +1,12 @@ +import React from 'react' + +// Create a runtime error. +if ('window' in global) { + throw Error('example runtime error') +} + +const Page = () => { + return
client-react-dev-overlay
+} + +export default Page diff --git a/test/development/client-dev-overlay/index.test.ts b/test/development/client-dev-overlay/index.test.ts new file mode 100644 index 0000000000000..a6763f22cc9be --- /dev/null +++ b/test/development/client-dev-overlay/index.test.ts @@ -0,0 +1,75 @@ +import { createNext, FileRef } from 'e2e-utils' +import webdriver from 'next-webdriver' +import { NextInstance } from 'test/lib/next-modes/base' +import { join } from 'path' +import { BrowserInterface } from 'test/lib/browsers/base' +import { check } from 'next-test-utils' + +describe('client-dev-overlay', () => { + let next: NextInstance + let browser: BrowserInterface + + beforeAll(async () => { + next = await createNext({ + files: { + pages: new FileRef(join(__dirname, 'app/pages')), + }, + }) + }) + beforeEach(async () => { + browser = await webdriver(next.url, '') + }) + afterAll(() => next.destroy()) + + // The `BrowserInterface.hasElementByCssSelector` cannot be used for elements inside a shadow DOM. + function elementExistsInNextJSPortalShadowDOM(selector: string) { + return browser.eval( + `!!document.querySelector('nextjs-portal').shadowRoot.querySelector('${selector}')` + ) as any + } + const selectors = { + fullScreenDialog: '[data-nextjs-dialog]', + toast: '[data-nextjs-toast]', + minimizeButton: '[data-nextjs-errors-dialog-left-right-close-button]', + hideButton: '[data-nextjs-toast-errors-hide-button]', + } + function getToast() { + return browser.elementByCss(selectors.toast) + } + function getMinimizeButton() { + return browser.elementByCss(selectors.minimizeButton) + } + function getHideButton() { + return browser.elementByCss(selectors.hideButton) + } + + it('should be able to fullscreen the minimized overlay', async () => { + await getMinimizeButton().click() + await getToast().click() + + await check(async () => { + return (await elementExistsInNextJSPortalShadowDOM( + selectors.fullScreenDialog + )) + ? 'success' + : 'missing' + }, 'success') + }) + + it('should be able to minimize the fullscreen overlay', async () => { + await getMinimizeButton().click() + expect(await elementExistsInNextJSPortalShadowDOM(selectors.toast)).toBe( + true + ) + }) + + it('should be able to hide the minimized overlay', async () => { + await getMinimizeButton().click() + await getHideButton().click() + + await check(async () => { + const exists = await elementExistsInNextJSPortalShadowDOM('div') + return exists ? 'found' : 'success' + }, 'success') + }) +})