diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx index a6932f7ab1dc..0aa668704aea 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx @@ -2,9 +2,9 @@ import type { CSSProperties } from 'react' import type { OverlayState, OverlayDispatch } from '../../shared' import type { DevToolsScale } from '../errors/dev-tools-indicator/dev-tools-info/preferences' -import { useState, useRef } from 'react' +import { useState } from 'react' +import { NextLogo } from './next-logo' import { Toast } from '../toast' -import { NextLogo } from '../errors/dev-tools-indicator/next-logo' import { MENU_CURVE, MENU_DURATION_MS, @@ -31,8 +31,6 @@ export function DevToolsIndicator({ const [open, setOpen] = useState(false) const [position, setPosition] = useState(getInitialPosition()) - const triggerRef = useRef(null) - const [vertical, horizontal] = position.split('-', 2) const toggleErrorOverlay = () => { @@ -66,7 +64,6 @@ export function DevToolsIndicator({ > {/* Trigger */} +): number { + const [width, setWidth] = useState(0) + + useEffect(() => { + const el = ref.current + + if (!el) { + return + } + + const observer = new ResizeObserver(([{ contentRect }]) => { + setWidth(contentRect.width) + }) + + observer.observe(el) + return () => observer.disconnect() + }, [ref]) + + return width +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/use-minimum-loading-time-multiple.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-minimum-loading-time-multiple.ts similarity index 100% rename from packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/use-minimum-loading-time-multiple.tsx rename to packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-minimum-loading-time-multiple.ts diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts new file mode 100644 index 000000000000..92a30fecba04 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react' + +export function useUpdateAnimation( + issueCount: number, + animationDurationMs = 0 +) { + const lastUpdatedTimeStamp = useRef(null) + const [animate, setAnimate] = useState(false) + + useEffect(() => { + if (issueCount > 0) { + const deltaMs = lastUpdatedTimeStamp.current + ? Date.now() - lastUpdatedTimeStamp.current + : -1 + lastUpdatedTimeStamp.current = Date.now() + + // We don't animate if `issueCount` changes too quickly + if (deltaMs <= animationDurationMs) { + return + } + + setAnimate(true) + // It is important to use a CSS transitioned state, not a CSS keyframed animation + // because if the issue count increases faster than the animation duration, it + // will abruptly stop and not transition smoothly back to its original state. + const timeoutId = window.setTimeout(() => { + setAnimate(false) + }, animationDurationMs) + + return () => { + clearTimeout(timeoutId) + } + } + }, [issueCount, animationDurationMs]) + + return animate +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx new file mode 100644 index 000000000000..baf0920e8a8f --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { NextLogo } from './next-logo' +import { withShadowPortal } from '../../storybook/with-shadow-portal' + +const meta: Meta = { + component: NextLogo, + parameters: { + layout: 'centered', + }, + args: { + 'aria-label': 'Open Next.js DevTools', + onClick: () => console.log('Clicked!'), + }, + decorators: [withShadowPortal], +} + +export default meta +type Story = StoryObj + +export const NoIssues: Story = { + args: { + issueCount: 0, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const SingleIssue: Story = { + args: { + issueCount: 1, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const MultipleIssues: Story = { + args: { + issueCount: 5, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const ManyIssues: Story = { + args: { + issueCount: 99, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const Building: Story = { + args: { + issueCount: 0, + isDevBuilding: true, + isDevRendering: false, + }, +} + +export const BuildingWithError: Story = { + args: { + issueCount: 1, + isDevBuilding: true, + isDevRendering: false, + }, +} + +export const Rendering: Story = { + args: { + issueCount: 0, + isDevBuilding: false, + isDevRendering: true, + }, +} + +export const RenderingWithError: Story = { + args: { + issueCount: 1, + isDevBuilding: false, + isDevRendering: true, + }, +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx new file mode 100644 index 000000000000..a575bbbd1b1a --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx @@ -0,0 +1,558 @@ +import type { DevToolsScale } from '../errors/dev-tools-indicator/dev-tools-info/preferences' + +import { useEffect, useRef, useState } from 'react' +import { useUpdateAnimation } from './hooks/use-update-animation' +import { useMeasureWidth } from './hooks/use-measure-width' +import { useMinimumLoadingTimeMultiple } from './hooks/use-minimum-loading-time-multiple' +import { Cross } from '../../icons/cross' +import { Warning } from '../../icons/warning' +import { css } from '../../utils/css' + +interface Props extends React.ComponentProps<'button'> { + issueCount: number + isDevBuilding: boolean + isDevRendering: boolean + isBuildError: boolean + onTriggerClick: () => void + toggleErrorOverlay: () => void + scale: DevToolsScale +} + +const SHORT_DURATION_MS = 150 + +export function NextLogo({ + disabled, + issueCount, + isDevBuilding, + isDevRendering, + isBuildError, + onTriggerClick, + toggleErrorOverlay, + scale = 1, + ...props +}: Props) { + const SIZE = 36 / scale + + const hasError = issueCount > 0 + const [isErrorExpanded, setIsErrorExpanded] = useState(hasError) + const [dismissed, setDismissed] = useState(false) + const newErrorDetected = useUpdateAnimation(issueCount, SHORT_DURATION_MS) + + const triggerRef = useRef(null) + const ref = useRef(null) + const measuredWidth = useMeasureWidth(ref) + + const isLoading = useMinimumLoadingTimeMultiple( + isDevBuilding || isDevRendering + ) + const isExpanded = isErrorExpanded || disabled + const width = measuredWidth === 0 ? 'auto' : measuredWidth + + useEffect(() => { + setIsErrorExpanded(hasError) + }, [hasError]) + + return ( +
+ {/* Styles */} + +
+
+ {/* Children */} + {!disabled && ( + + )} + {isExpanded && ( +
+ + {!isBuildError && ( + + )} +
+ )} +
+
+
+
+ ) +} + +function AnimateCount({ + children: count, + animate = true, + ...props +}: { + children: number + animate: boolean +}) { + return ( +
+
+ {count - 1} +
+
+ {count} +
+
+ ) +} + +function NextMark({ + isLoading, + isDevBuilding, +}: { + isLoading?: boolean + isDevBuilding?: boolean +}) { + const strokeColor = isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white' + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx b/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx index 10ff35365ae3..6f59f4c7a1cf 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx @@ -1,8 +1,10 @@ import { forwardRef, useEffect, useRef, useState } from 'react' import { css } from '../../../utils/css' import mergeRefs from '../../../utils/merge-refs' -import { useMinimumLoadingTimeMultiple } from './use-minimum-loading-time-multiple' import type { DevToolsScale } from './dev-tools-info/preferences' +import { useMinimumLoadingTimeMultiple } from '../../devtools-indicator/hooks/use-minimum-loading-time-multiple' +import { useUpdateAnimation } from '../../devtools-indicator/hooks/use-update-animation' +import { useMeasureWidth } from '../../devtools-indicator/hooks/use-measure-width' interface Props extends React.ComponentProps<'button'> { issueCount: number @@ -487,60 +489,6 @@ function AnimateCount({ ) } -function useMeasureWidth(ref: React.RefObject): number { - const [width, setWidth] = useState(0) - - useEffect(() => { - const el = ref.current - - if (!el) { - return - } - - const observer = new ResizeObserver(([{ contentRect }]) => { - setWidth(contentRect.width) - }) - - observer.observe(el) - return () => observer.disconnect() - }, [ref]) - - return width -} - -function useUpdateAnimation(issueCount: number, animationDurationMs = 0) { - const lastUpdatedTimeStamp = useRef(null) - const [animate, setAnimate] = useState(false) - - useEffect(() => { - if (issueCount > 0) { - const deltaMs = lastUpdatedTimeStamp.current - ? Date.now() - lastUpdatedTimeStamp.current - : -1 - lastUpdatedTimeStamp.current = Date.now() - - // We don't animate if `issueCount` changes too quickly - if (deltaMs <= animationDurationMs) { - return - } - - setAnimate(true) - // It is important to use a CSS transitioned state, not a CSS keyframed animation - // because if the issue count increases faster than the animation duration, it - // will abruptly stop and not transition smoothly back to its original state. - const timeoutId = window.setTimeout(() => { - setAnimate(false) - }, animationDurationMs) - - return () => { - clearTimeout(timeoutId) - } - } - }, [issueCount, animationDurationMs]) - - return animate -} - function NextMark({ isLoading, isDevBuilding, diff --git a/packages/next/src/next-devtools/dev-overlay/icons/cross.tsx b/packages/next/src/next-devtools/dev-overlay/icons/cross.tsx new file mode 100644 index 000000000000..578d8629967b --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/icons/cross.tsx @@ -0,0 +1,19 @@ +export function Cross(props: React.SVGProps) { + return ( + + + + ) +} diff --git a/packages/next/src/next-devtools/dev-overlay/icons/warning.tsx b/packages/next/src/next-devtools/dev-overlay/icons/warning.tsx new file mode 100644 index 000000000000..8ff102e5053d --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/icons/warning.tsx @@ -0,0 +1,19 @@ +export function Warning(props: React.SVGProps) { + return ( + + + + ) +}