diff --git a/packages/next/client/components/hot-reloader.tsx b/packages/next/client/components/hot-reloader.tsx index f03e2f62c3ef3..0a0ecc11260ec 100644 --- a/packages/next/client/components/hot-reloader.tsx +++ b/packages/next/client/components/hot-reloader.tsx @@ -15,7 +15,7 @@ import { onBuildOk, onRefresh, ReactDevOverlay, -} from 'next/dist/compiled/@next/react-dev-overlay/dist/client' +} from './react-dev-overlay/src/client' import stripAnsi from 'next/dist/compiled/strip-ansi' import formatWebpackMessages from '../dev/error-overlay/format-webpack-messages' import { useRouter } from './hooks-client' diff --git a/packages/next/client/components/react-dev-overlay/src/client.ts b/packages/next/client/components/react-dev-overlay/src/client.ts new file mode 100644 index 0000000000000..a9eb0a496eea5 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/client.ts @@ -0,0 +1,95 @@ +import * as Bus from './internal/bus' +import { parseStack } from './internal/helpers/parseStack' + +let isRegistered = false +let stackTraceLimit: number | undefined = undefined + +function onUnhandledError(ev: ErrorEvent) { + const error = ev?.error + if (!error || !(error instanceof Error) || typeof error.stack !== 'string') { + // A non-error was thrown, we don't have anything to show. :-( + return + } + + if ( + error.message.match(/(hydration|content does not match|did not match)/i) + ) { + error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error` + } + + const e = error + Bus.emit({ + type: Bus.TYPE_UNHANDLED_ERROR, + reason: error, + frames: parseStack(e.stack!), + }) +} + +function onUnhandledRejection(ev: PromiseRejectionEvent) { + const reason = ev?.reason + if ( + !reason || + !(reason instanceof Error) || + typeof reason.stack !== 'string' + ) { + // A non-error was thrown, we don't have anything to show. :-( + return + } + + const e = reason + Bus.emit({ + type: Bus.TYPE_UNHANDLED_REJECTION, + reason: reason, + frames: parseStack(e.stack!), + }) +} + +function register() { + if (isRegistered) { + return + } + isRegistered = true + + try { + const limit = Error.stackTraceLimit + Error.stackTraceLimit = 50 + stackTraceLimit = limit + } catch {} + + window.addEventListener('error', onUnhandledError) + window.addEventListener('unhandledrejection', onUnhandledRejection) +} + +function unregister() { + if (!isRegistered) { + return + } + isRegistered = false + + if (stackTraceLimit !== undefined) { + try { + Error.stackTraceLimit = stackTraceLimit + } catch {} + stackTraceLimit = undefined + } + + window.removeEventListener('error', onUnhandledError) + window.removeEventListener('unhandledrejection', onUnhandledRejection) +} + +function onBuildOk() { + Bus.emit({ type: Bus.TYPE_BUILD_OK }) +} + +function onBuildError(message: string) { + Bus.emit({ type: Bus.TYPE_BUILD_ERROR, message }) +} + +function onRefresh() { + Bus.emit({ type: Bus.TYPE_REFRESH }) +} + +export { getErrorByType } from './internal/helpers/getErrorByType' +export { getServerError } from './internal/helpers/nodeStackFrames' +export { default as ReactDevOverlay } from './internal/ReactDevOverlay' +export { onBuildOk, onBuildError, register, unregister, onRefresh } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/ErrorBoundary.tsx b/packages/next/client/components/react-dev-overlay/src/internal/ErrorBoundary.tsx new file mode 100644 index 0000000000000..7f836313c510b --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +import React from 'react' + +type ErrorBoundaryProps = { + onError: (error: Error, componentStack: string | null) => void + globalOverlay?: boolean + isMounted?: boolean +} +type ErrorBoundaryState = { error: Error | null } + +class ErrorBoundary extends React.PureComponent< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch( + error: Error, + // Loosely typed because it depends on the React version and was + // accidentally excluded in some versions. + errorInfo?: { componentStack?: string | null } + ) { + this.props.onError(error, errorInfo?.componentStack || null) + if (!this.props.globalOverlay) { + this.setState({ error }) + } + } + + render() { + // The component has to be unmounted or else it would continue to error + return this.state.error || + (this.props.globalOverlay && this.props.isMounted) ? ( + // When the overlay is global for the application and it wraps a component rendering `` + // we have to render the html shell otherwise the shadow root will not be able to attach + this.props.globalOverlay ? ( + + + + + ) : null + ) : ( + this.props.children + ) + } +} + +export { ErrorBoundary } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/ReactDevOverlay.tsx b/packages/next/client/components/react-dev-overlay/src/internal/ReactDevOverlay.tsx new file mode 100644 index 0000000000000..707e982dcb50a --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/ReactDevOverlay.tsx @@ -0,0 +1,127 @@ +import * as React from 'react' + +import * as Bus from './bus' +import { ShadowPortal } from './components/ShadowPortal' +import { BuildError } from './container/BuildError' +import { Errors, SupportedErrorEvent } from './container/Errors' +import { ErrorBoundary } from './ErrorBoundary' +import { Base } from './styles/Base' +import { ComponentStyles } from './styles/ComponentStyles' +import { CssReset } from './styles/CssReset' + +type OverlayState = { + nextId: number + buildError: string | null + errors: SupportedErrorEvent[] +} + +function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState { + switch (ev.type) { + case Bus.TYPE_BUILD_OK: { + return { ...state, buildError: null } + } + case Bus.TYPE_BUILD_ERROR: { + return { ...state, buildError: ev.message } + } + case Bus.TYPE_REFRESH: { + return { ...state, buildError: null, errors: [] } + } + case Bus.TYPE_UNHANDLED_ERROR: + case Bus.TYPE_UNHANDLED_REJECTION: { + return { + ...state, + nextId: state.nextId + 1, + errors: [ + ...state.errors.filter((err) => { + // Filter out duplicate errors + return err.event.reason !== ev.reason + }), + { id: state.nextId, event: ev }, + ], + } + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _: never = ev + return state + } + } +} + +type ErrorType = 'runtime' | 'build' + +const shouldPreventDisplay = ( + errorType?: ErrorType | null, + preventType?: ErrorType[] | null +) => { + if (!preventType || !errorType) { + return false + } + return preventType.includes(errorType) +} + +type ReactDevOverlayProps = { + children?: React.ReactNode + preventDisplay?: ErrorType[] + globalOverlay?: boolean +} + +const ReactDevOverlay: React.FunctionComponent = + function ReactDevOverlay({ children, preventDisplay, globalOverlay }) { + const [state, dispatch] = React.useReducer< + React.Reducer + >(reducer, { + nextId: 1, + buildError: null, + errors: [], + }) + + React.useEffect(() => { + Bus.on(dispatch) + return function () { + Bus.off(dispatch) + } + }, [dispatch]) + + const onComponentError = React.useCallback( + (_error: Error, _componentStack: string | null) => { + // TODO: special handling + }, + [] + ) + + const hasBuildError = state.buildError != null + const hasRuntimeErrors = Boolean(state.errors.length) + + const isMounted = hasBuildError || hasRuntimeErrors + + return ( + + + {children ?? null} + + {isMounted ? ( + + + + + + {shouldPreventDisplay( + hasBuildError ? 'build' : hasRuntimeErrors ? 'runtime' : null, + preventDisplay + ) ? null : hasBuildError ? ( + + ) : hasRuntimeErrors ? ( + + ) : undefined} + + ) : undefined} + + ) + } + +export default ReactDevOverlay diff --git a/packages/next/client/components/react-dev-overlay/src/internal/bus.ts b/packages/next/client/components/react-dev-overlay/src/internal/bus.ts new file mode 100644 index 0000000000000..f14d8806bbce1 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/bus.ts @@ -0,0 +1,76 @@ +import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' + +export const TYPE_BUILD_OK = 'build-ok' +export const TYPE_BUILD_ERROR = 'build-error' +export const TYPE_REFRESH = 'fast-refresh' +export const TYPE_UNHANDLED_ERROR = 'unhandled-error' +export const TYPE_UNHANDLED_REJECTION = 'unhandled-rejection' + +export type BuildOk = { type: typeof TYPE_BUILD_OK } +export type BuildError = { + type: typeof TYPE_BUILD_ERROR + message: string +} +export type FastRefresh = { type: typeof TYPE_REFRESH } +export type UnhandledError = { + type: typeof TYPE_UNHANDLED_ERROR + reason: Error + frames: StackFrame[] +} +export type UnhandledRejection = { + type: typeof TYPE_UNHANDLED_REJECTION + reason: Error + frames: StackFrame[] +} +export type BusEvent = + | BuildOk + | BuildError + | FastRefresh + | UnhandledError + | UnhandledRejection + +export type BusEventHandler = (ev: BusEvent) => void + +let handlers: Set = new Set() +let queue: BusEvent[] = [] + +function drain() { + // Draining should never happen synchronously in case multiple handlers are + // registered. + setTimeout(function () { + while ( + // Until we are out of events: + Boolean(queue.length) && + // Or, if all handlers removed themselves as a result of handling the + // event(s) + Boolean(handlers.size) + ) { + const ev = queue.shift()! + handlers.forEach((handler) => handler(ev)) + } + }, 1) +} + +export function emit(ev: BusEvent): void { + queue.push(Object.freeze({ ...ev })) + drain() +} + +export function on(fn: BusEventHandler): boolean { + if (handlers.has(fn)) { + return false + } + + handlers.add(fn) + drain() + return true +} + +export function off(fn: BusEventHandler): boolean { + if (handlers.has(fn)) { + handlers.delete(fn) + return true + } + + return false +} diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/CodeFrame.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/CodeFrame.tsx new file mode 100644 index 0000000000000..ba548f3965ab5 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/CodeFrame.tsx @@ -0,0 +1,114 @@ +import Anser from 'next/dist/compiled/anser' +import * as React from 'react' +import { StackFrame } from 'next/dist/compiled/stacktrace-parser' +import stripAnsi from 'next/dist/compiled/strip-ansi' +import { getFrameSource } from '../../helpers/stack-frame' + +export type CodeFrameProps = { stackFrame: StackFrame; codeFrame: string } + +export const CodeFrame: React.FC = function CodeFrame({ + stackFrame, + codeFrame, +}) { + // Strip leading spaces out of the code frame: + const formattedFrame = React.useMemo(() => { + const lines = codeFrame.split(/\r?\n/g) + const prefixLength = lines + .map((line) => + /^>? +\d+ +\| [ ]+/.exec(stripAnsi(line)) === null + ? null + : /^>? +\d+ +\| ( *)/.exec(stripAnsi(line)) + ) + .filter(Boolean) + .map((v) => v!.pop()!) + .reduce((c, n) => (isNaN(c) ? n.length : Math.min(c, n.length)), NaN) + + if (prefixLength > 1) { + const p = ' '.repeat(prefixLength) + return lines + .map((line, a) => + ~(a = line.indexOf('|')) + ? line.substring(0, a) + line.substring(a).replace(p, '') + : line + ) + .join('\n') + } + return lines.join('\n') + }, [codeFrame]) + + const decoded = React.useMemo(() => { + return Anser.ansiToJson(formattedFrame, { + json: true, + use_classes: true, + remove_empty: true, + }) + }, [formattedFrame]) + + const open = React.useCallback(() => { + const params = new URLSearchParams() + for (const key in stackFrame) { + params.append(key, ((stackFrame as any)[key] ?? '').toString()) + } + + self + .fetch( + `${ + process.env.__NEXT_ROUTER_BASEPATH || '' + }/__nextjs_launch-editor?${params.toString()}` + ) + .then( + () => {}, + () => { + console.error('There was an issue opening this code in your editor.') + } + ) + }, [stackFrame]) + + // TODO: make the caret absolute + return ( +
+
+

+ + {getFrameSource(stackFrame)} @ {stackFrame.methodName} + + + + + + +

+
+
+        {decoded.map((entry, index) => (
+          
+            {entry.content}
+          
+        ))}
+      
+
+ ) +} diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/index.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/index.tsx new file mode 100644 index 0000000000000..59818df56eddb --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/index.tsx @@ -0,0 +1 @@ +export { CodeFrame } from './CodeFrame' diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/styles.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/styles.tsx new file mode 100644 index 0000000000000..324cc5fedc9c8 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/CodeFrame/styles.tsx @@ -0,0 +1,52 @@ +import { noop as css } from '../../helpers/noop-template' + +const styles = css` + [data-nextjs-codeframe] { + overflow: auto; + border-radius: var(--size-gap-half); + background-color: var(--color-ansi-bg); + color: var(--color-ansi-fg); + } + [data-nextjs-codeframe]::selection, + [data-nextjs-codeframe] *::selection { + background-color: var(--color-ansi-selection); + } + [data-nextjs-codeframe] * { + color: inherit; + background-color: transparent; + font-family: var(--font-stack-monospace); + } + + [data-nextjs-codeframe] > * { + margin: 0; + padding: calc(var(--size-gap) + var(--size-gap-half)) + calc(var(--size-gap-double) + var(--size-gap-half)); + } + [data-nextjs-codeframe] > div { + display: inline-block; + width: auto; + min-width: 100%; + border-bottom: 1px solid var(--color-ansi-bright-black); + } + [data-nextjs-codeframe] > div > p { + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + margin: 0; + } + [data-nextjs-codeframe] > div > p:hover { + text-decoration: underline dotted; + } + [data-nextjs-codeframe] div > p > svg { + width: auto; + height: 1em; + margin-left: 8px; + } + [data-nextjs-codeframe] div > pre { + overflow: hidden; + display: inline-block; + } +` + +export { styles } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/Dialog.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/Dialog.tsx new file mode 100644 index 0000000000000..e01f36c2c3341 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/Dialog.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import { useOnClickOutside } from '../../hooks/use-on-click-outside' + +export type DialogProps = { + type: 'error' | 'warning' + 'aria-labelledby': string + 'aria-describedby': string + onClose?: (e: MouseEvent | TouchEvent) => void +} + +const Dialog: React.FC = function Dialog({ + children, + type, + onClose, + ...props +}) { + const [dialog, setDialog] = React.useState(null) + const onDialog = React.useCallback((node) => { + setDialog(node) + }, []) + useOnClickOutside(dialog, onClose) + + // Make HTMLElements with `role=link` accessible to be triggered by the + // keyboard, i.e. [Enter]. + React.useEffect(() => { + if (dialog == null) { + return + } + + const root = dialog.getRootNode() + // Always true, but we do this for TypeScript: + if (!(root instanceof ShadowRoot)) { + return + } + const shadowRoot = root + function handler(e: KeyboardEvent) { + const el = shadowRoot.activeElement + if ( + e.key === 'Enter' && + el instanceof HTMLElement && + el.getAttribute('role') === 'link' + ) { + e.preventDefault() + e.stopPropagation() + + el.click() + } + } + + shadowRoot.addEventListener('keydown', handler as EventListener) + return () => + shadowRoot.removeEventListener('keydown', handler as EventListener) + }, [dialog]) + + return ( +
+
+ {children} +
+ ) +} + +export { Dialog } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogBody.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogBody.tsx new file mode 100644 index 0000000000000..17eadbc187ee5 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogBody.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' + +export type DialogBodyProps = { + className?: string +} + +const DialogBody: React.FC = function DialogBody({ + children, + className, +}) { + return ( +
+ {children} +
+ ) +} + +export { DialogBody } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogContent.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogContent.tsx new file mode 100644 index 0000000000000..780fe85fa679c --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogContent.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' + +export type DialogContentProps = { + className?: string +} + +const DialogContent: React.FC = function DialogContent({ + children, + className, +}) { + return ( +
+ {children} +
+ ) +} + +export { DialogContent } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogHeader.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogHeader.tsx new file mode 100644 index 0000000000000..2748398ac9da3 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/DialogHeader.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' + +export type DialogHeaderProps = { + className?: string +} + +const DialogHeader: React.FC = function DialogHeader({ + children, + className, +}) { + return ( +
+ {children} +
+ ) +} + +export { DialogHeader } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/index.ts b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/index.ts new file mode 100644 index 0000000000000..15a3c57a88686 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/index.ts @@ -0,0 +1,5 @@ +export { Dialog } from './Dialog' +export { DialogBody } from './DialogBody' +export { DialogContent } from './DialogContent' +export { DialogHeader } from './DialogHeader' +export { styles } from './styles' diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/styles.ts b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/styles.ts new file mode 100644 index 0000000000000..d4730a77c85a2 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Dialog/styles.ts @@ -0,0 +1,91 @@ +import { noop as css } from '../../helpers/noop-template' + +const styles = css` + [data-nextjs-dialog] { + display: flex; + flex-direction: column; + width: 100%; + margin-right: auto; + margin-left: auto; + outline: none; + background: white; + border-radius: var(--size-gap); + box-shadow: 0 var(--size-gap-half) var(--size-gap-double) + rgba(0, 0, 0, 0.25); + max-height: calc(100% - 56px); + overflow-y: hidden; + } + + @media (max-height: 812px) { + [data-nextjs-dialog-overlay] { + max-height: calc(100% - 15px); + } + } + + @media (min-width: 576px) { + [data-nextjs-dialog] { + max-width: 540px; + box-shadow: 0 var(--size-gap) var(--size-gap-quad) rgba(0, 0, 0, 0.25); + } + } + + @media (min-width: 768px) { + [data-nextjs-dialog] { + max-width: 720px; + } + } + + @media (min-width: 992px) { + [data-nextjs-dialog] { + max-width: 960px; + } + } + + [data-nextjs-dialog-banner] { + position: relative; + } + [data-nextjs-dialog-banner].banner-warning { + border-color: var(--color-ansi-yellow); + } + [data-nextjs-dialog-banner].banner-error { + border-color: var(--color-ansi-red); + } + + [data-nextjs-dialog-banner]::after { + z-index: 2; + content: ''; + position: absolute; + top: 0; + right: 0; + width: 100%; + /* banner width: */ + border-top-width: var(--size-gap-half); + border-bottom-width: 0; + border-top-style: solid; + border-bottom-style: solid; + border-top-color: inherit; + border-bottom-color: transparent; + } + + [data-nextjs-dialog-content] { + overflow-y: auto; + border: none; + margin: 0; + /* calc(padding + banner width offset) */ + padding: calc(var(--size-gap-double) + var(--size-gap-half)) + var(--size-gap-double); + height: 100%; + display: flex; + flex-direction: column; + } + [data-nextjs-dialog-content] > [data-nextjs-dialog-header] { + flex-shrink: 0; + margin-bottom: var(--size-gap-double); + } + [data-nextjs-dialog-content] > [data-nextjs-dialog-body] { + position: relative; + flex: 1 1 auto; + } +` + +export { styles } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx new file mode 100644 index 0000000000000..cb8b722894484 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/LeftRightDialogHeader.tsx @@ -0,0 +1,166 @@ +import * as React from 'react' +import { CloseIcon } from '../../icons/CloseIcon' + +export type LeftRightDialogHeaderProps = { + className?: string + previous: (() => void) | null + next: (() => void) | null + close?: () => void +} + +const LeftRightDialogHeader: React.FC = + function LeftRightDialogHeader({ + children, + className, + previous, + next, + close, + }) { + const buttonLeft = React.useRef(null) + const buttonRight = React.useRef(null) + const buttonClose = React.useRef(null) + + const [nav, setNav] = React.useState(null) + const onNav = React.useCallback((el: HTMLElement) => { + setNav(el) + }, []) + + React.useEffect(() => { + if (nav == null) { + return + } + + const root = nav.getRootNode() + const d = self.document + + function handler(e: KeyboardEvent) { + if (e.key === 'ArrowLeft') { + e.stopPropagation() + if (buttonLeft.current) { + buttonLeft.current.focus() + } + previous && previous() + } else if (e.key === 'ArrowRight') { + e.stopPropagation() + if (buttonRight.current) { + buttonRight.current.focus() + } + next && next() + } else if (e.key === 'Escape') { + e.stopPropagation() + if (root instanceof ShadowRoot) { + const a = root.activeElement + if (a && a !== buttonClose.current && a instanceof HTMLElement) { + a.blur() + return + } + } + + if (close) { + close() + } + } + } + + root.addEventListener('keydown', handler as EventListener) + if (root !== d) { + d.addEventListener('keydown', handler) + } + return function () { + root.removeEventListener('keydown', handler as EventListener) + if (root !== d) { + d.removeEventListener('keydown', handler) + } + } + }, [close, nav, next, previous]) + + // Unlock focus for browsers like Firefox, that break all user focus if the + // currently focused item becomes disabled. + React.useEffect(() => { + if (nav == null) { + return + } + + const root = nav.getRootNode() + // Always true, but we do this for TypeScript: + if (root instanceof ShadowRoot) { + const a = root.activeElement + + if (previous == null) { + if (buttonLeft.current && a === buttonLeft.current) { + buttonLeft.current.blur() + } + } else if (next == null) { + if (buttonRight.current && a === buttonRight.current) { + buttonRight.current.blur() + } + } + } + }, [nav, next, previous]) + + return ( +
+ + {close ? ( + + ) : null} +
+ ) + } + +export { LeftRightDialogHeader } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/index.ts b/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/index.ts new file mode 100644 index 0000000000000..b63edb12ba22a --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/index.ts @@ -0,0 +1,2 @@ +export { LeftRightDialogHeader } from './LeftRightDialogHeader' +export { styles } from './styles' diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/styles.ts b/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/styles.ts new file mode 100644 index 0000000000000..f7368bb31d1ee --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/LeftRightDialogHeader/styles.ts @@ -0,0 +1,61 @@ +import { noop as css } from '../../helpers/noop-template' + +const styles = css` + [data-nextjs-dialog-left-right] { + display: flex; + flex-direction: row; + align-content: center; + align-items: center; + justify-content: space-between; + } + [data-nextjs-dialog-left-right] > nav > button { + display: inline-flex; + align-items: center; + justify-content: center; + + width: calc(var(--size-gap-double) + var(--size-gap)); + height: calc(var(--size-gap-double) + var(--size-gap)); + font-size: 0; + border: none; + background-color: rgba(255, 85, 85, 0.1); + color: var(--color-ansi-red); + cursor: pointer; + transition: background-color 0.25s ease; + } + [data-nextjs-dialog-left-right] > nav > button > svg { + width: auto; + height: calc(var(--size-gap) + var(--size-gap-half)); + } + [data-nextjs-dialog-left-right] > nav > button:hover { + background-color: rgba(255, 85, 85, 0.2); + } + [data-nextjs-dialog-left-right] > nav > button:disabled { + background-color: rgba(255, 85, 85, 0.1); + color: rgba(255, 85, 85, 0.4); + cursor: not-allowed; + } + + [data-nextjs-dialog-left-right] > nav > button:first-of-type { + border-radius: var(--size-gap-half) 0 0 var(--size-gap-half); + margin-right: 1px; + } + [data-nextjs-dialog-left-right] > nav > button:last-of-type { + border-radius: 0 var(--size-gap-half) var(--size-gap-half) 0; + } + + [data-nextjs-dialog-left-right] > button:last-of-type { + border: 0; + padding: 0; + + background-color: transparent; + appearance: none; + + opacity: 0.4; + transition: opacity 0.25s ease; + } + [data-nextjs-dialog-left-right] > button:last-of-type:hover { + opacity: 0.7; + } +` + +export { styles } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/Overlay.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/Overlay.tsx new file mode 100644 index 0000000000000..22249c716d91d --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/Overlay.tsx @@ -0,0 +1,47 @@ +// @ts-ignore +import allyTrap from './maintain--tab-focus' +import * as React from 'react' +import { lock, unlock } from './body-locker' + +export type OverlayProps = { className?: string; fixed?: boolean } + +const Overlay: React.FC = function Overlay({ + className, + children, + fixed, +}) { + React.useEffect(() => { + lock() + return () => { + unlock() + } + }, []) + + const [overlay, setOverlay] = React.useState(null) + const onOverlay = React.useCallback((el: HTMLDivElement) => { + setOverlay(el) + }, []) + + React.useEffect(() => { + if (overlay == null) { + return + } + + const handle2 = allyTrap({ context: overlay }) + return () => { + handle2.disengage() + } + }, [overlay]) + + return ( +
+
+ {children} +
+ ) +} + +export { Overlay } diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/body-locker.ts b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/body-locker.ts new file mode 100644 index 0000000000000..8e33e1c8d9924 --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/body-locker.ts @@ -0,0 +1,41 @@ +let previousBodyPaddingRight: string | undefined +let previousBodyOverflowSetting: string | undefined + +let activeLocks = 0 + +export function lock() { + setTimeout(() => { + if (activeLocks++ > 0) { + return + } + + const scrollBarGap = + window.innerWidth - document.documentElement.clientWidth + + if (scrollBarGap > 0) { + previousBodyPaddingRight = document.body.style.paddingRight + document.body.style.paddingRight = `${scrollBarGap}px` + } + + previousBodyOverflowSetting = document.body.style.overflow + document.body.style.overflow = 'hidden' + }) +} + +export function unlock() { + setTimeout(() => { + if (activeLocks === 0 || --activeLocks !== 0) { + return + } + + if (previousBodyPaddingRight !== undefined) { + document.body.style.paddingRight = previousBodyPaddingRight + previousBodyPaddingRight = undefined + } + + if (previousBodyOverflowSetting !== undefined) { + document.body.style.overflow = previousBodyOverflowSetting + previousBodyOverflowSetting = undefined + } + }) +} diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/index.tsx b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/index.tsx new file mode 100644 index 0000000000000..9f8311327322f --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/index.tsx @@ -0,0 +1 @@ +export { Overlay } from './Overlay' diff --git a/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/maintain--tab-focus.ts b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/maintain--tab-focus.ts new file mode 100644 index 0000000000000..465ddf5b60aea --- /dev/null +++ b/packages/next/client/components/react-dev-overlay/src/internal/components/Overlay/maintain--tab-focus.ts @@ -0,0 +1,3569 @@ +/* eslint-disable */ +// @ts-nocheck +// Copied from https://github.com/medialize/ally.js +// License: MIT +// Copyright (c) 2015 Rodney Rehm +// +// Entrypoint: ally.js/maintain/tab-focus + +import _platform from 'next/dist/compiled/platform' +import cssEscape from 'next/dist/compiled/css.escape' + +// input may be undefined, selector-tring, Node, NodeList, HTMLCollection, array of Nodes +// yes, to some extent this is a bad replica of jQuery's constructor function +function nodeArray(input) { + if (!input) { + return [] + } + + if (Array.isArray(input)) { + return input + } + + // instanceof Node - does not work with iframes + if (input.nodeType !== undefined) { + return [input] + } + + if (typeof input === 'string') { + input = document.querySelectorAll(input) + } + + if (input.length !== undefined) { + return [].slice.call(input, 0) + } + + throw new TypeError('unexpected input ' + String(input)) +} + +function contextToElement(_ref) { + var context = _ref.context, + _ref$label = _ref.label, + label = _ref$label === undefined ? 'context-to-element' : _ref$label, + resolveDocument = _ref.resolveDocument, + defaultToDocument = _ref.defaultToDocument + + var element = nodeArray(context)[0] + + if (resolveDocument && element && element.nodeType === Node.DOCUMENT_NODE) { + element = element.documentElement + } + + if (!element && defaultToDocument) { + return document.documentElement + } + + if (!element) { + throw new TypeError(label + ' requires valid options.context') + } + + if ( + element.nodeType !== Node.ELEMENT_NODE && + element.nodeType !== Node.DOCUMENT_FRAGMENT_NODE + ) { + throw new TypeError(label + ' requires options.context to be an Element') + } + + return element +} + +function getShadowHost() { + var _ref = + arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + context = _ref.context + + var element = contextToElement({ + label: 'get/shadow-host', + context: context, + }) + + // walk up to the root + var container = null + + while (element) { + container = element + element = element.parentNode + } + + // https://developer.mozilla.org/en-US/docs/Web/API/Node.nodeType + // NOTE: Firefox 34 does not expose ShadowRoot.host (but 37 does) + if ( + container.nodeType === container.DOCUMENT_FRAGMENT_NODE && + container.host + ) { + // the root is attached to a fragment node that has a host + return container.host + } + + return null +} + +function getDocument(node) { + if (!node) { + return document + } + + if (node.nodeType === Node.DOCUMENT_NODE) { + return node + } + + return node.ownerDocument || document +} + +function isActiveElement(context) { + var element = contextToElement({ + label: 'is/active-element', + resolveDocument: true, + context: context, + }) + + var _document = getDocument(element) + if (_document.activeElement === element) { + return true + } + + var shadowHost = getShadowHost({ context: element }) + if (shadowHost && shadowHost.shadowRoot.activeElement === element) { + return true + } + + return false +} + +// [elem, elem.parent, elem.parent.parent, …, html] +// will not contain the shadowRoot (DOCUMENT_FRAGMENT_NODE) and shadowHost +function getParents() { + var _ref = + arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + context = _ref.context + + var list = [] + var element = contextToElement({ + label: 'get/parents', + context: context, + }) + + while (element) { + list.push(element) + // IE does know support parentElement on SVGElement + element = element.parentNode + if (element && element.nodeType !== Node.ELEMENT_NODE) { + element = null + } + } + + return list +} + +// Element.prototype.matches may be available at a different name +// https://developer.mozilla.org/en/docs/Web/API/Element/matches + +var names = [ + 'matches', + 'webkitMatchesSelector', + 'mozMatchesSelector', + 'msMatchesSelector', +] +var name = null + +function findMethodName(element) { + names.some(function (_name) { + if (!element[_name]) { + return false + } + + name = _name + return true + }) +} + +function elementMatches(element, selector) { + if (!name) { + findMethodName(element) + } + + return element[name](selector) +} + +// deep clone of original platform +var platform = JSON.parse(JSON.stringify(_platform)) + +// operating system +var os = platform.os.family || '' +var ANDROID = os === 'Android' +var WINDOWS = os.slice(0, 7) === 'Windows' +var OSX = os === 'OS X' +var IOS = os === 'iOS' + +// layout +var BLINK = platform.layout === 'Blink' +var GECKO = platform.layout === 'Gecko' +var TRIDENT = platform.layout === 'Trident' +var EDGE = platform.layout === 'EdgeHTML' +var WEBKIT = platform.layout === 'WebKit' + +// browser version (not layout engine version!) +var version = parseFloat(platform.version) +var majorVersion = Math.floor(version) +platform.majorVersion = majorVersion + +platform.is = { + // operating system + ANDROID: ANDROID, + WINDOWS: WINDOWS, + OSX: OSX, + IOS: IOS, + // layout + BLINK: BLINK, // "Chrome", "Chrome Mobile", "Opera" + GECKO: GECKO, // "Firefox" + TRIDENT: TRIDENT, // "Internet Explorer" + EDGE: EDGE, // "Microsoft Edge" + WEBKIT: WEBKIT, // "Safari" + // INTERNET EXPLORERS + IE9: TRIDENT && majorVersion === 9, + IE10: TRIDENT && majorVersion === 10, + IE11: TRIDENT && majorVersion === 11, +} + +function before() { + var data = { + // remember what had focus to restore after test + activeElement: document.activeElement, + // remember scroll positions to restore after test + windowScrollTop: window.scrollTop, + windowScrollLeft: window.scrollLeft, + bodyScrollTop: document.body.scrollTop, + bodyScrollLeft: document.body.scrollLeft, + } + + // wrap tests in an element hidden from screen readers to prevent them + // from announcing focus, which can be quite irritating to the user + var iframe = document.createElement('iframe') + iframe.setAttribute( + 'style', + 'position:absolute; position:fixed; top:0; left:-2px; width:1px; height:1px; overflow:hidden;' + ) + iframe.setAttribute('aria-live', 'off') + iframe.setAttribute('aria-busy', 'true') + iframe.setAttribute('aria-hidden', 'true') + document.body.appendChild(iframe) + + var _window = iframe.contentWindow + var _document = _window.document + + _document.open() + _document.close() + var wrapper = _document.createElement('div') + _document.body.appendChild(wrapper) + + data.iframe = iframe + data.wrapper = wrapper + data.window = _window + data.document = _document + + return data +} + +// options.element: +// {string} element name +// {function} callback(wrapper, document) to generate an element +// options.mutate: (optional) +// {function} callback(element, wrapper, document) to manipulate element prior to focus-test. +// Can return DOMElement to define focus target (default: element) +// options.validate: (optional) +// {function} callback(element, focusTarget, document) to manipulate test-result +function test(data, options) { + // make sure we operate on a clean slate + data.wrapper.innerHTML = '' + // create dummy element to test focusability of + var element = + typeof options.element === 'string' + ? data.document.createElement(options.element) + : options.element(data.wrapper, data.document) + // allow callback to further specify dummy element + // and optionally define element to focus + var focus = + options.mutate && options.mutate(element, data.wrapper, data.document) + if (!focus && focus !== false) { + focus = element + } + // element needs to be part of the DOM to be focusable + !element.parentNode && data.wrapper.appendChild(element) + // test if the element with invalid tabindex can be focused + focus && focus.focus && focus.focus() + // validate test's result + return options.validate + ? options.validate(element, focus, data.document) + : data.document.activeElement === focus +} + +function after(data) { + // restore focus to what it was before test and cleanup + if (data.activeElement === document.body) { + document.activeElement && + document.activeElement.blur && + document.activeElement.blur() + if (platform.is.IE10) { + // IE10 does not redirect focus to when the activeElement is removed + document.body.focus() + } + } else { + data.activeElement && data.activeElement.focus && data.activeElement.focus() + } + + document.body.removeChild(data.iframe) + + // restore scroll position + window.scrollTop = data.windowScrollTop + window.scrollLeft = data.windowScrollLeft + document.body.scrollTop = data.bodyScrollTop + document.body.scrollLeft = data.bodyScrollLeft +} + +function detectFocus(tests) { + var data = before() + + var results = {} + Object.keys(tests).map(function (key) { + results[key] = test(data, tests[key]) + }) + + after(data) + return results +} + +// this file is overwritten by `npm run build:pre` +var version$1 = '1.4.1' + +/* + Facility to cache test results in localStorage. + + USAGE: + cache.get('key'); + cache.set('key', 'value'); + */ + +function readLocalStorage(key) { + // allow reading from storage to retrieve previous support results + // even while the document does not have focus + var data = void 0 + + try { + data = window.localStorage && window.localStorage.getItem(key) + data = data ? JSON.parse(data) : {} + } catch (e) { + data = {} + } + + return data +} + +function writeLocalStorage(key, value) { + if (!document.hasFocus()) { + // if the document does not have focus when tests are executed, focus() may + // not be handled properly and events may not be dispatched immediately. + // This can happen when a document is reloaded while Developer Tools have focus. + try { + window.localStorage && window.localStorage.removeItem(key) + } catch (e) { + // ignore + } + + return + } + + try { + window.localStorage && + window.localStorage.setItem(key, JSON.stringify(value)) + } catch (e) { + // ignore + } +} + +var userAgent = + (typeof window !== 'undefined' && window.navigator.userAgent) || '' +var cacheKey = 'ally-supports-cache' +var cache = readLocalStorage(cacheKey) + +// update the cache if ally or the user agent changed (newer version, etc) +if (cache.userAgent !== userAgent || cache.version !== version$1) { + cache = {} +} + +cache.userAgent = userAgent +cache.version = version$1 + +var cache$1 = { + get: function get() { + return cache + }, + set: function set(values) { + Object.keys(values).forEach(function (key) { + cache[key] = values[key] + }) + + cache.time = new Date().toISOString() + writeLocalStorage(cacheKey, cache) + }, +} + +function cssShadowPiercingDeepCombinator() { + var combinator = void 0 + + // see https://dev.w3.org/csswg/css-scoping-1/#deep-combinator + // https://bugzilla.mozilla.org/show_bug.cgi?id=1117572 + // https://code.google.com/p/chromium/issues/detail?id=446051 + try { + document.querySelector('html >>> :first-child') + combinator = '>>>' + } catch (noArrowArrowArrow) { + try { + // old syntax supported at least up to Chrome 41 + // https://code.google.com/p/chromium/issues/detail?id=446051 + document.querySelector('html /deep/ :first-child') + combinator = '/deep/' + } catch (noDeep) { + combinator = '' + } + } + + return combinator +} + +var gif = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-usemap +var focusAreaImgTabindex = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = + '' + + '' + + '' + + return element.querySelector('area') + }, +} + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-usemap +var focusAreaTabindex = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = + '' + + '' + + '' + + return false + }, + validate: function validate(element, focusTarget, _document) { + if (platform.is.GECKO) { + // fixes https://github.com/medialize/ally.js/issues/35 + // Firefox loads the DataURI asynchronously, causing a false-negative + return true + } + + var focus = element.querySelector('area') + focus.focus() + return _document.activeElement === focus + }, +} + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-usemap +var focusAreaWithoutHref = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = + '' + + '' + + '' + + return element.querySelector('area') + }, + validate: function validate(element, focusTarget, _document) { + if (platform.is.GECKO) { + // fixes https://github.com/medialize/ally.js/issues/35 + // Firefox loads the DataURI asynchronously, causing a false-negative + return true + } + + return _document.activeElement === focusTarget + }, +} + +var focusAudioWithoutControls = { + name: 'can-focus-audio-without-controls', + element: 'audio', + mutate: function mutate(element) { + try { + // invalid media file can trigger warning in console, data-uri to prevent HTTP request + element.setAttribute('src', gif) + } catch (e) { + // IE9 may throw "Error: Not implemented" + } + }, +} + +var invalidGif = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ' + +// NOTE: https://github.com/medialize/ally.js/issues/35 +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-usemap +var focusBrokenImageMap = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = + '' + + '' + + return element.querySelector('area') + }, +} + +// Children of focusable elements with display:flex are focusable in IE10-11 +var focusChildrenOfFocusableFlexbox = { + element: 'div', + mutate: function mutate(element) { + element.setAttribute('tabindex', '-1') + element.setAttribute( + 'style', + 'display: -webkit-flex; display: -ms-flexbox; display: flex;' + ) + element.innerHTML = 'hello' + return element.querySelector('span') + }, +} + +// fieldset[tabindex=0][disabled] should not be focusable, but Blink and WebKit disagree +// @specification https://www.w3.org/TR/html5/disabled-elements.html#concept-element-disabled +// @browser-issue Chromium https://crbug.com/453847 +// @browser-issue WebKit https://bugs.webkit.org/show_bug.cgi?id=141086 +var focusFieldsetDisabled = { + element: 'fieldset', + mutate: function mutate(element) { + element.setAttribute('tabindex', 0) + element.setAttribute('disabled', 'disabled') + }, +} + +var focusFieldset = { + element: 'fieldset', + mutate: function mutate(element) { + element.innerHTML = 'legend

content

' + }, +} + +// elements with display:flex are focusable in IE10-11 +var focusFlexboxContainer = { + element: 'span', + mutate: function mutate(element) { + element.setAttribute( + 'style', + 'display: -webkit-flex; display: -ms-flexbox; display: flex;' + ) + element.innerHTML = 'hello' + }, +} + +// form[tabindex=0][disabled] should be focusable as the +// specification doesn't know the disabled attribute on the form element +// @specification https://www.w3.org/TR/html5/forms.html#the-form-element +var focusFormDisabled = { + element: 'form', + mutate: function mutate(element) { + element.setAttribute('tabindex', 0) + element.setAttribute('disabled', 'disabled') + }, +} + +// NOTE: https://github.com/medialize/ally.js/issues/35 +// fixes https://github.com/medialize/ally.js/issues/20 +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-ismap +var focusImgIsmap = { + element: 'a', + mutate: function mutate(element) { + element.href = '#void' + element.innerHTML = '' + return element.querySelector('img') + }, +} + +// NOTE: https://github.com/medialize/ally.js/issues/35 +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-usemap +var focusImgUsemapTabindex = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = + '' + + '' + + return element.querySelector('img') + }, +} + +var focusInHiddenIframe = { + element: function element(wrapper, _document) { + var iframe = _document.createElement('iframe') + + // iframe must be part of the DOM before accessing the contentWindow is possible + wrapper.appendChild(iframe) + + // create the iframe's default document () + var iframeDocument = iframe.contentWindow.document + iframeDocument.open() + iframeDocument.close() + return iframe + }, + mutate: function mutate(iframe) { + iframe.style.visibility = 'hidden' + + var iframeDocument = iframe.contentWindow.document + var input = iframeDocument.createElement('input') + iframeDocument.body.appendChild(input) + return input + }, + validate: function validate(iframe) { + var iframeDocument = iframe.contentWindow.document + var focus = iframeDocument.querySelector('input') + return iframeDocument.activeElement === focus + }, +} + +var result = !platform.is.WEBKIT + +function focusInZeroDimensionObject() { + return result +} + +// Firefox allows *any* value and treats invalid values like tabindex="-1" +// @browser-issue Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054 +var focusInvalidTabindex = { + element: 'div', + mutate: function mutate(element) { + element.setAttribute('tabindex', 'invalid-value') + }, +} + +var focusLabelTabindex = { + element: 'label', + mutate: function mutate(element) { + element.setAttribute('tabindex', '-1') + }, + validate: function validate(element, focusTarget, _document) { + // force layout in Chrome 49, otherwise the element won't be focusable + /* eslint-disable no-unused-vars */ + var variableToPreventDeadCodeElimination = element.offsetHeight + /* eslint-enable no-unused-vars */ + element.focus() + return _document.activeElement === element + }, +} + +var svg = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtb' + + 'G5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiBpZD0ic3ZnIj48dGV4dCB4PSIxMCIgeT0iMjAiIGlkPSJ' + + 'zdmctbGluay10ZXh0Ij50ZXh0PC90ZXh0Pjwvc3ZnPg==' + +// Note: IE10 on BrowserStack does not like this test + +var focusObjectSvgHidden = { + element: 'object', + mutate: function mutate(element) { + element.setAttribute('type', 'image/svg+xml') + element.setAttribute('data', svg) + element.setAttribute('width', '200') + element.setAttribute('height', '50') + element.style.visibility = 'hidden' + }, +} + +// Note: IE10 on BrowserStack does not like this test + +var focusObjectSvg = { + name: 'can-focus-object-svg', + element: 'object', + mutate: function mutate(element) { + element.setAttribute('type', 'image/svg+xml') + element.setAttribute('data', svg) + element.setAttribute('width', '200') + element.setAttribute('height', '50') + }, + validate: function validate(element, focusTarget, _document) { + if (platform.is.GECKO) { + // Firefox seems to be handling the object creation asynchronously and thereby produces a false negative test result. + // Because we know Firefox is able to focus object elements referencing SVGs, we simply cheat by sniffing the user agent string + return true + } + + return _document.activeElement === element + }, +} + +// Every Environment except IE9 considers SWF objects focusable +var result$1 = !platform.is.IE9 + +function focusObjectSwf() { + return result$1 +} + +var focusRedirectImgUsemap = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = + '' + + '' + + // focus the , not the
+ return element.querySelector('img') + }, + validate: function validate(element, focusTarget, _document) { + var target = element.querySelector('area') + return _document.activeElement === target + }, +} + +// see https://jsbin.com/nenirisage/edit?html,js,console,output + +var focusRedirectLegend = { + element: 'fieldset', + mutate: function mutate(element) { + element.innerHTML = + 'legend' + // take care of focus in validate(); + return false + }, + validate: function validate(element, focusTarget, _document) { + var focusable = element.querySelector('input[tabindex="-1"]') + var tabbable = element.querySelector('input[tabindex="0"]') + + // Firefox requires this test to focus the
first, while this is not necessary in + // https://jsbin.com/nenirisage/edit?html,js,console,output + element.focus() + + element.querySelector('legend').focus() + return ( + (_document.activeElement === focusable && 'focusable') || + (_document.activeElement === tabbable && 'tabbable') || + '' + ) + }, +} + +// https://github.com/medialize/ally.js/issues/21 +var focusScrollBody = { + element: 'div', + mutate: function mutate(element) { + element.setAttribute('style', 'width: 100px; height: 50px; overflow: auto;') + element.innerHTML = + '
scrollable content
' + return element.querySelector('div') + }, +} + +// https://github.com/medialize/ally.js/issues/21 +var focusScrollContainerWithoutOverflow = { + element: 'div', + mutate: function mutate(element) { + element.setAttribute('style', 'width: 100px; height: 50px;') + element.innerHTML = + '
scrollable content
' + }, +} + +// https://github.com/medialize/ally.js/issues/21 +var focusScrollContainer = { + element: 'div', + mutate: function mutate(element) { + element.setAttribute('style', 'width: 100px; height: 50px; overflow: auto;') + element.innerHTML = + '
scrollable content
' + }, +} + +var focusSummary = { + element: 'details', + mutate: function mutate(element) { + element.innerHTML = 'foo

content

' + return element.firstElementChild + }, +} + +function makeFocusableForeignObject() { + // Constructs + // without raising a Trusted Types violation + var foreignObject = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'foreignObject' + ) + foreignObject.width.baseVal.value = 30 + foreignObject.height.baseVal.value = 30 + foreignObject.appendChild(document.createElement('input')) + foreignObject.lastChild.type = 'text' + + return foreignObject +} + +function focusSvgForeignObjectHack(element) { + // Edge13, Edge14: foreignObject focus hack + // https://jsbin.com/kunehinugi/edit?html,js,output + // https://jsbin.com/fajagi/3/edit?html,js,output + var isSvgElement = + element.ownerSVGElement || element.nodeName.toLowerCase() === 'svg' + if (!isSvgElement) { + return false + } + + // inject and focus an element into the SVG element to receive focus + var foreignObject = makeFocusableForeignObject() + element.appendChild(foreignObject) + var input = foreignObject.querySelector('input') + input.focus() + + // upon disabling the activeElement, IE and Edge + // will not shift focus to like all the other + // browsers, but instead find the first focusable + // ancestor and shift focus to that + input.disabled = true + + // clean up + element.removeChild(foreignObject) + return true +} + +function generate(element) { + return ( + '' + + element + + '' + ) +} + +function focus(element) { + if (element.focus) { + return + } + + try { + HTMLElement.prototype.focus.call(element) + } catch (e) { + focusSvgForeignObjectHack(element) + } +} + +function validate(element, focusTarget, _document) { + focus(focusTarget) + return _document.activeElement === focusTarget +} + +var focusSvgFocusableAttribute = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = generate('a') + return element.querySelector('text') + }, + validate: validate, +} + +var focusSvgTabindexAttribute = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = generate('a') + return element.querySelector('text') + }, + validate: validate, +} + +var focusSvgNegativeTabindexAttribute = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = generate('a') + return element.querySelector('text') + }, + validate: validate, +} + +var focusSvgUseTabindex = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = generate( + [ + 'link', + '', + ].join('') + ) + + return element.querySelector('use') + }, + validate: validate, +} + +var focusSvgForeignobjectTabindex = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = generate( + '' + ) + // Safari 8's quersSelector() can't identify foreignObject, but getElementyByTagName() can + return ( + element.querySelector('foreignObject') || + element.getElementsByTagName('foreignObject')[0] + ) + }, + validate: validate, +} + +// Firefox seems to be handling the SVG-document-in-iframe creation asynchronously +// and thereby produces a false negative test result. Thus the test is pointless +// and we resort to UA sniffing once again. +// see http://jsbin.com/vunadohoko/1/edit?js,console,output + +var result$2 = Boolean( + platform.is.GECKO && + typeof SVGElement !== 'undefined' && + SVGElement.prototype.focus +) + +function focusSvgInIframe() { + return result$2 +} + +var focusSvg = { + element: 'div', + mutate: function mutate(element) { + element.innerHTML = generate('') + return element.firstChild + }, + validate: validate, +} + +// Firefox allows *any* value and treats invalid values like tabindex="-1" +// @browser-issue Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054 +var focusTabindexTrailingCharacters = { + element: 'div', + mutate: function mutate(element) { + element.setAttribute('tabindex', '3x') + }, +} + +var focusTable = { + element: 'table', + mutate: function mutate(element, wrapper, _document) { + // IE9 has a problem replacing TBODY contents with innerHTML. + // https://stackoverflow.com/a/8097055/515124 + // element.innerHTML = 'cell'; + var fragment = _document.createDocumentFragment() + fragment.innerHTML = 'cell' + element.appendChild(fragment) + }, +} + +var focusVideoWithoutControls = { + element: 'video', + mutate: function mutate(element) { + try { + // invalid media file can trigger warning in console, data-uri to prevent HTTP request + element.setAttribute('src', gif) + } catch (e) { + // IE9 may throw "Error: Not implemented" + } + }, +} + +// https://jsbin.com/vafaba/3/edit?html,js,console,output +var result$3 = platform.is.GECKO || platform.is.TRIDENT || platform.is.EDGE + +function tabsequenceAreaAtImgPosition() { + return result$3 +} + +var testCallbacks = { + cssShadowPiercingDeepCombinator: cssShadowPiercingDeepCombinator, + focusInZeroDimensionObject: focusInZeroDimensionObject, + focusObjectSwf: focusObjectSwf, + focusSvgInIframe: focusSvgInIframe, + tabsequenceAreaAtImgPosition: tabsequenceAreaAtImgPosition, +} + +var testDescriptions = { + focusAreaImgTabindex: focusAreaImgTabindex, + focusAreaTabindex: focusAreaTabindex, + focusAreaWithoutHref: focusAreaWithoutHref, + focusAudioWithoutControls: focusAudioWithoutControls, + focusBrokenImageMap: focusBrokenImageMap, + focusChildrenOfFocusableFlexbox: focusChildrenOfFocusableFlexbox, + focusFieldsetDisabled: focusFieldsetDisabled, + focusFieldset: focusFieldset, + focusFlexboxContainer: focusFlexboxContainer, + focusFormDisabled: focusFormDisabled, + focusImgIsmap: focusImgIsmap, + focusImgUsemapTabindex: focusImgUsemapTabindex, + focusInHiddenIframe: focusInHiddenIframe, + focusInvalidTabindex: focusInvalidTabindex, + focusLabelTabindex: focusLabelTabindex, + focusObjectSvg: focusObjectSvg, + focusObjectSvgHidden: focusObjectSvgHidden, + focusRedirectImgUsemap: focusRedirectImgUsemap, + focusRedirectLegend: focusRedirectLegend, + focusScrollBody: focusScrollBody, + focusScrollContainerWithoutOverflow: focusScrollContainerWithoutOverflow, + focusScrollContainer: focusScrollContainer, + focusSummary: focusSummary, + focusSvgFocusableAttribute: focusSvgFocusableAttribute, + focusSvgTabindexAttribute: focusSvgTabindexAttribute, + focusSvgNegativeTabindexAttribute: focusSvgNegativeTabindexAttribute, + focusSvgUseTabindex: focusSvgUseTabindex, + focusSvgForeignobjectTabindex: focusSvgForeignobjectTabindex, + focusSvg: focusSvg, + focusTabindexTrailingCharacters: focusTabindexTrailingCharacters, + focusTable: focusTable, + focusVideoWithoutControls: focusVideoWithoutControls, +} + +function executeTests() { + var results = detectFocus(testDescriptions) + Object.keys(testCallbacks).forEach(function (key) { + results[key] = testCallbacks[key]() + }) + + return results +} + +var supportsCache = null + +function _supports() { + if (supportsCache) { + return supportsCache + } + + supportsCache = cache$1.get() + if (!supportsCache.time) { + cache$1.set(executeTests()) + supportsCache = cache$1.get() + } + + return supportsCache +} + +var supports = void 0 + +// https://www.w3.org/TR/html5/infrastructure.html#rules-for-parsing-integers +// NOTE: all browsers agree to allow trailing spaces as well +var validIntegerPatternNoTrailing = /^\s*(-|\+)?[0-9]+\s*$/ +var validIntegerPatternWithTrailing = /^\s*(-|\+)?[0-9]+.*$/ + +function isValidTabindex(context) { + if (!supports) { + supports = _supports() + } + + var validIntegerPattern = supports.focusTabindexTrailingCharacters + ? validIntegerPatternWithTrailing + : validIntegerPatternNoTrailing + + var element = contextToElement({ + label: 'is/valid-tabindex', + resolveDocument: true, + context: context, + }) + + // Edge 14 has a capitalization problem on SVG elements, + // see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9282058/ + var hasTabindex = element.hasAttribute('tabindex') + var hasTabIndex = element.hasAttribute('tabIndex') + + if (!hasTabindex && !hasTabIndex) { + return false + } + + // older Firefox and Internet Explorer don't support tabindex on SVG elements + var isSvgElement = + element.ownerSVGElement || element.nodeName.toLowerCase() === 'svg' + if (isSvgElement && !supports.focusSvgTabindexAttribute) { + return false + } + + // @browser-issue Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054 + if (supports.focusInvalidTabindex) { + return true + } + + // an element matches the tabindex selector even if its value is invalid + var tabindex = element.getAttribute(hasTabindex ? 'tabindex' : 'tabIndex') + // IE11 parses tabindex="" as the value "-32768" + // @browser-issue Trident https://connect.microsoft.com/IE/feedback/details/1072965 + if (tabindex === '-32768') { + return false + } + + return Boolean(tabindex && validIntegerPattern.test(tabindex)) +} + +function tabindexValue(element) { + if (!isValidTabindex(element)) { + return null + } + + // Edge 14 has a capitalization problem on SVG elements, + // see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/9282058/ + var hasTabindex = element.hasAttribute('tabindex') + var attributeName = hasTabindex ? 'tabindex' : 'tabIndex' + + // @browser-issue Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054 + var tabindex = parseInt(element.getAttribute(attributeName), 10) + return isNaN(tabindex) ? -1 : tabindex +} + +// this is a shared utility file for focus-relevant.js and tabbable.js +// separate testing of this file's functions is not necessary, +// as they're implicitly tested by way of the consumers + +function isUserModifyWritable(style) { + // https://www.w3.org/TR/1999/WD-css3-userint-19990916#user-modify + // https://github.com/medialize/ally.js/issues/17 + var userModify = style.webkitUserModify || '' + return Boolean(userModify && userModify.indexOf('write') !== -1) +} + +function hasCssOverflowScroll(style) { + return [ + style.getPropertyValue('overflow'), + style.getPropertyValue('overflow-x'), + style.getPropertyValue('overflow-y'), + ].some(function (overflow) { + return overflow === 'auto' || overflow === 'scroll' + }) +} + +function hasCssDisplayFlex(style) { + return style.display.indexOf('flex') > -1 +} + +function isScrollableContainer(element, nodeName, parentNodeName, parentStyle) { + if (nodeName !== 'div' && nodeName !== 'span') { + // Internet Explorer advances scrollable containers and bodies to focusable + // only if the scrollable container is
or - this does *not* + // happen for
,
, … + return false + } + + if ( + parentNodeName && + parentNodeName !== 'div' && + parentNodeName !== 'span' && + !hasCssOverflowScroll(parentStyle) + ) { + return false + } + + return ( + element.offsetHeight < element.scrollHeight || + element.offsetWidth < element.scrollWidth + ) +} + +var supports$1 = void 0 + +function isFocusRelevantRules() { + var _ref = + arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + context = _ref.context, + _ref$except = _ref.except, + except = + _ref$except === undefined + ? { + flexbox: false, + scrollable: false, + shadow: false, + } + : _ref$except + + if (!supports$1) { + supports$1 = _supports() + } + + var element = contextToElement({ + label: 'is/focus-relevant', + resolveDocument: true, + context: context, + }) + + if (!except.shadow && element.shadowRoot) { + // a ShadowDOM host receives focus when the focus moves to its content + return true + } + + var nodeName = element.nodeName.toLowerCase() + + if (nodeName === 'input' && element.type === 'hidden') { + // input[type="hidden"] supports.cannot be focused + return false + } + + if ( + nodeName === 'input' || + nodeName === 'select' || + nodeName === 'button' || + nodeName === 'textarea' + ) { + return true + } + + if (nodeName === 'legend' && supports$1.focusRedirectLegend) { + // specifics filtered in is/focusable + return true + } + + if (nodeName === 'label') { + // specifics filtered in is/focusable + return true + } + + if (nodeName === 'area') { + // specifics filtered in is/focusable + return true + } + + if (nodeName === 'a' && element.hasAttribute('href')) { + return true + } + + if (nodeName === 'object' && element.hasAttribute('usemap')) { + // object[usemap] is not focusable in any browser + return false + } + + if (nodeName === 'object') { + var svgType = element.getAttribute('type') + if (!supports$1.focusObjectSvg && svgType === 'image/svg+xml') { + // object[type="image/svg+xml"] is not focusable in Internet Explorer + return false + } else if ( + !supports$1.focusObjectSwf && + svgType === 'application/x-shockwave-flash' + ) { + // object[type="application/x-shockwave-flash"] is not focusable in Internet Explorer 9 + return false + } + } + + if (nodeName === 'iframe' || nodeName === 'object') { + // browsing context containers + return true + } + + if (nodeName === 'embed' || nodeName === 'keygen') { + // embed is considered focus-relevant but not focusable + // see https://github.com/medialize/ally.js/issues/82 + return true + } + + if (element.hasAttribute('contenteditable')) { + // also see CSS property user-modify below + return true + } + + if ( + nodeName === 'audio' && + (supports$1.focusAudioWithoutControls || element.hasAttribute('controls')) + ) { + return true + } + + if ( + nodeName === 'video' && + (supports$1.focusVideoWithoutControls || element.hasAttribute('controls')) + ) { + return true + } + + if (supports$1.focusSummary && nodeName === 'summary') { + return true + } + + var validTabindex = isValidTabindex(element) + + if (nodeName === 'img' && element.hasAttribute('usemap')) { + // Gecko, Trident and Edge do not allow an image with an image map and tabindex to be focused, + // it appears the tabindex is overruled so focus is still forwarded to the + return ( + (validTabindex && supports$1.focusImgUsemapTabindex) || + supports$1.focusRedirectImgUsemap + ) + } + + if (supports$1.focusTable && (nodeName === 'table' || nodeName === 'td')) { + // IE10-11 supports.can focus and
+ return true + } + + if (supports$1.focusFieldset && nodeName === 'fieldset') { + // IE10-11 supports.can focus
+ return true + } + + var isSvgElement = nodeName === 'svg' + var isSvgContent = element.ownerSVGElement + var focusableAttribute = element.getAttribute('focusable') + var tabindex = tabindexValue(element) + + if ( + nodeName === 'use' && + tabindex !== null && + !supports$1.focusSvgUseTabindex + ) { + // cannot be made focusable by adding a tabindex attribute anywhere but Blink and WebKit + return false + } + + if (nodeName === 'foreignobject') { + // can only be made focusable in Blink and WebKit + return tabindex !== null && supports$1.focusSvgForeignobjectTabindex + } + + if (elementMatches(element, 'svg a') && element.hasAttribute('xlink:href')) { + return true + } + + if ( + (isSvgElement || isSvgContent) && + element.focus && + !supports$1.focusSvgNegativeTabindexAttribute && + tabindex < 0 + ) { + // Firefox 51 and 52 treat any natively tabbable SVG element with + // tabindex="-1" as tabbable and everything else as inert + // see https://bugzilla.mozilla.org/show_bug.cgi?id=1302340 + return false + } + + if (isSvgElement) { + return ( + validTabindex || + supports$1.focusSvg || + supports$1.focusSvgInIframe || + // Internet Explorer understands the focusable attribute introduced in SVG Tiny 1.2 + Boolean( + supports$1.focusSvgFocusableAttribute && + focusableAttribute && + focusableAttribute === 'true' + ) + ) + } + + if (isSvgContent) { + if (supports$1.focusSvgTabindexAttribute && validTabindex) { + return true + } + + if (supports$1.focusSvgFocusableAttribute) { + // Internet Explorer understands the focusable attribute introduced in SVG Tiny 1.2 + return focusableAttribute === 'true' + } + } + + // https://www.w3.org/TR/html5/editing.html#sequential-focus-navigation-and-the-tabindex-attribute + if (validTabindex) { + return true + } + + var style = window.getComputedStyle(element, null) + if (isUserModifyWritable(style)) { + return true + } + + if ( + supports$1.focusImgIsmap && + nodeName === 'img' && + element.hasAttribute('ismap') + ) { + // IE10-11 considers the in focusable + // https://github.com/medialize/ally.js/issues/20 + var hasLinkParent = getParents({ context: element }).some(function ( + parent + ) { + return ( + parent.nodeName.toLowerCase() === 'a' && parent.hasAttribute('href') + ) + }) + + if (hasLinkParent) { + return true + } + } + + // https://github.com/medialize/ally.js/issues/21 + if (!except.scrollable && supports$1.focusScrollContainer) { + if (supports$1.focusScrollContainerWithoutOverflow) { + // Internet Explorer does will consider the scrollable area focusable + // if the element is a
or a and it is in fact scrollable, + // regardless of the CSS overflow property + if (isScrollableContainer(element, nodeName)) { + return true + } + } else if (hasCssOverflowScroll(style)) { + // Firefox requires proper overflow setting, IE does not necessarily + // https://developer.mozilla.org/en-US/docs/Web/CSS/overflow + return true + } + } + + if ( + !except.flexbox && + supports$1.focusFlexboxContainer && + hasCssDisplayFlex(style) + ) { + // elements with display:flex are focusable in IE10-11 + return true + } + + var parent = element.parentElement + if (!except.scrollable && parent) { + var parentNodeName = parent.nodeName.toLowerCase() + var parentStyle = window.getComputedStyle(parent, null) + if ( + supports$1.focusScrollBody && + isScrollableContainer(parent, nodeName, parentNodeName, parentStyle) + ) { + // scrollable bodies are focusable Internet Explorer + // https://github.com/medialize/ally.js/issues/21 + return true + } + + // Children of focusable elements with display:flex are focusable in IE10-11 + if (supports$1.focusChildrenOfFocusableFlexbox) { + if (hasCssDisplayFlex(parentStyle)) { + return true + } + } + } + + // NOTE: elements marked as inert are not focusable, + // but that property is not exposed to the DOM + // https://www.w3.org/TR/html5/editing.html#inert + + return false +} + +// bind exceptions to an iterator callback +isFocusRelevantRules.except = function () { + var except = + arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {} + + var isFocusRelevant = function isFocusRelevant(context) { + return isFocusRelevantRules({ + context: context, + except: except, + }) + } + + isFocusRelevant.rules = isFocusRelevantRules + return isFocusRelevant +} + +// provide isFocusRelevant(context) as default iterator callback +var isFocusRelevant = isFocusRelevantRules.except({}) + +function findIndex(array, callback) { + // attempt to use native or polyfilled Array#findIndex first + if (array.findIndex) { + return array.findIndex(callback) + } + + var length = array.length + + // shortcut if the array is empty + if (length === 0) { + return -1 + } + + // otherwise loop over array + for (var i = 0; i < length; i++) { + if (callback(array[i], i, array)) { + return i + } + } + + return -1 +} + +function getContentDocument(node) { + try { + // works on and