next.js/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx
next-logo.tsx635 lines19.5 KB
import { useRef, useState } from 'react'
import { useUpdateAnimation } from './hooks/use-update-animation'
import { useMeasureWidth } from './hooks/use-measure-width'
import { Cross } from '../../icons/cross'
import { Warning } from '../../icons/warning'
import { css } from '../../utils/css'
import { useDevOverlayContext } from '../../../dev-overlay.browser'
import { useRenderErrorContext } from '../../dev-overlay'
import { useDelayedRender } from '../../hooks/use-delayed-render'
import { useDebouncedValue } from '../../hooks/use-debounced-value'
import {
  ACTION_ERROR_OVERLAY_CLOSE,
  ACTION_ERROR_OVERLAY_OPEN,
} from '../../shared'
import { usePanelRouterContext } from '../../menu/context'
import { BASE_LOGO_SIZE } from '../../utils/indicator-metrics'
import { StatusIndicator, Status, getCurrentStatus } from './status-indicator'

const SHORT_DURATION_MS = 150

// Smooth out rapid status transitions driven by bursty HMR events (e.g.
// Compiling→None→Compiling when consecutive compile episodes are <300ms apart,
// or Compiling→Rendering→Compiling oscillation). The debounce bridges burst
// gaps and active↔active flicker.
const STATUS_DEBOUNCE_MS = 300

export function NextLogo({
  onTriggerClick,
  ...buttonProps
}: { onTriggerClick: () => void } & React.ComponentProps<'button'>) {
  const { state, dispatch } = useDevOverlayContext()
  const { totalErrorCount } = useRenderErrorContext()
  const SIZE = BASE_LOGO_SIZE / state.scale
  const { panel, triggerRef, setPanel } = usePanelRouterContext()
  const isMenuOpen = panel === 'panel-selector'

  const hasError = totalErrorCount > 0
  const [isErrorExpanded, setIsErrorExpanded] = useState(hasError)
  const [previousHasError, setPreviousHasError] = useState(hasError)
  if (previousHasError !== hasError) {
    setPreviousHasError(hasError)
    // Reset the expanded state when the error state changes
    setIsErrorExpanded(hasError)
  }
  const [dismissed, setDismissed] = useState(false)
  const newErrorDetected = useUpdateAnimation(
    totalErrorCount,
    SHORT_DURATION_MS
  )

  // Cache indicator state management
  const isCacheBypassing = state.cacheIndicator === 'bypass'

  // Get the current status from the state. Debounce active↔active transitions
  // (e.g. Compiling→Rendering) so bursts of HMR events don't flicker the
  // badge. Transitions to None are committed immediately so fast single builds
  // cancel the enter timer before the pill becomes visible.
  const currentStatus = useDebouncedValue(
    getCurrentStatus(
      state.buildingIndicator,
      state.renderingIndicator,
      state.cacheIndicator
    ),
    STATUS_DEBOUNCE_MS,
    {
      // Only None→active is immediate (no debounce on top of enterDelay).
      // active→None is debounced so short inter-burst gaps don't prematurely
      // collapse the pill. Fast single builds (completing in <enterDelay-debounce ms)
      // still stay hidden because the debounced None commits before enterDelay fires.
      leading: (prev: Status) => prev === Status.None,
    }
  )

  // Determine if we should show any status (excluding cache bypass, which renders like error badge)
  const shouldShowStatus = currentStatus !== Status.None

  // Delay showing for 400ms to catch fast operations,
  // and keep visible for minimum time (longer for warnings)
  const { rendered: showStatusIndicator } = useDelayedRender(shouldShowStatus, {
    enterDelay: 400,
    exitDelay: 500,
  })

  const ref = useRef<HTMLDivElement | null>(null)
  const measuredWidth = useMeasureWidth(ref)

  const displayStatus = showStatusIndicator ? currentStatus : Status.None

  const isExpanded =
    isErrorExpanded ||
    isCacheBypassing ||
    showStatusIndicator ||
    state.disableDevIndicator
  const width = measuredWidth === 0 ? 'auto' : measuredWidth

  return (
    <div
      data-next-badge-root
      style={
        {
          '--size': `${SIZE}px`,
          '--duration-short': `${SHORT_DURATION_MS}ms`,
          // if the indicator is disabled, hide the badge
          // also allow the "disabled" state be dismissed, as long as there are no build errors
          display:
            state.disableDevIndicator && (!hasError || dismissed)
              ? 'none'
              : 'block',
        } as React.CSSProperties
      }
    >
      {/* Styles */}
      <style>
        {css`
          [data-next-badge-root] {
            --timing: cubic-bezier(0.23, 0.88, 0.26, 0.92);
            --duration-long: 250ms;
            --color-outer-border: #171717;
            --color-inner-border: hsla(0, 0%, 100%, 0.14);
            --color-hover-alpha-subtle: hsla(0, 0%, 100%, 0.13);
            --color-hover-alpha-error: hsla(0, 0%, 100%, 0.2);
            --color-hover-alpha-error-2: hsla(0, 0%, 100%, 0.25);
            --mark-size: calc(var(--size) - var(--size-2) * 2);

            --focus-color: var(--color-blue-800);
            --focus-ring: 2px solid var(--focus-color);

            &:has([data-next-badge][data-error='true']) {
              --focus-color: #fff;
            }
          }

          [data-disabled-icon] {
            display: flex;
            align-items: center;
            justify-content: center;
            padding-right: 4px;
          }

          [data-next-badge] {
            width: var(--size);
            height: var(--size);
            display: flex;
            align-items: center;
            position: relative;
            background: rgba(0, 0, 0, 0.8);
            box-shadow:
              0 0 0 1px var(--color-outer-border),
              inset 0 0 0 1px var(--color-inner-border),
              0px 16px 32px -8px rgba(0, 0, 0, 0.24);
            backdrop-filter: blur(48px);
            border-radius: var(--rounded-full);
            user-select: none;
            cursor: pointer;
            scale: 1;
            overflow: hidden;
            will-change: scale, box-shadow, width, background;
            transition:
              scale var(--duration-short) var(--timing),
              width var(--duration-long) var(--timing),
              box-shadow var(--duration-long) var(--timing),
              background var(--duration-short) ease;

            &:active[data-error='false'] {
              scale: 0.95;
            }

            &[data-animate='true']:not(:hover) {
              scale: 1.02;
            }

            &[data-error='false']:has([data-next-mark]:focus-visible) {
              outline: var(--focus-ring);
              outline-offset: 3px;
            }

            &[data-error='true'] {
              background: #ca2a30;
              --color-inner-border: #e5484d;

              [data-next-mark] {
                background: var(--color-hover-alpha-error);
                outline-offset: 0px;

                &:focus-visible {
                  outline: var(--focus-ring);
                  outline-offset: -1px;
                }

                &:hover {
                  background: var(--color-hover-alpha-error-2);
                }
              }
            }

            &[data-cache-bypassing='true']:not([data-error='true']) {
              background: rgba(217, 119, 6, 0.95);
              --color-inner-border: rgba(245, 158, 11, 0.9);

              [data-issues-open] {
                color: white;
              }
            }

            &[data-error-expanded='false'][data-error='true'] ~ [data-dot] {
              scale: 1;
            }

            > div {
              display: flex;
            }
          }

          [data-issues-collapse]:focus-visible {
            outline: var(--focus-ring);
          }

          [data-issues]:has([data-issues-open]:focus-visible) {
            outline: var(--focus-ring);
            outline-offset: -1px;
          }

          [data-dot] {
            content: '';
            width: var(--size-8);
            height: var(--size-8);
            background: #fff;
            box-shadow: 0 0 0 1px var(--color-outer-border);
            border-radius: 50%;
            position: absolute;
            top: 2px;
            right: 0px;
            scale: 0;
            pointer-events: none;
            transition: scale 200ms var(--timing);
            transition-delay: var(--duration-short);
          }

          [data-issues] {
            --padding-left: 8px;
            display: flex;
            gap: 2px;
            align-items: center;
            padding-left: 8px;
            padding-right: 8px;
            height: var(--size-32);
            margin-right: 2px;
            border-radius: var(--rounded-full);
            transition: background var(--duration-short) ease;

            &:has([data-issues-open]:hover) {
              background: var(--color-hover-alpha-error);
            }

            &:has([data-issues-collapse]) {
              padding-right: calc(var(--padding-left) / 2);
            }
          }

          [data-issues-open] {
            font-size: var(--size-13);
            color: white;
            width: fit-content;
            height: 100%;
            display: flex;
            gap: 2px;
            align-items: center;
            margin: 0;
            line-height: var(--size-36);
            font-weight: 500;
            z-index: 2;
            white-space: nowrap;

            &:focus-visible {
              outline: 0;
            }
          }

          [data-issues-collapse] {
            width: var(--size-24);
            height: var(--size-24);
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: var(--rounded-full);
            transition: background var(--duration-short) ease;

            &:hover {
              background: var(--color-hover-alpha-error);
            }
          }

          [data-cross] {
            color: #fff;
            width: var(--size-12);
            height: var(--size-12);
          }

          [data-next-mark] {
            width: var(--mark-size);
            height: var(--mark-size);
            margin: 0 2px;
            display: flex;
            align-items: center;
            border-radius: var(--rounded-full);
            transition: background var(--duration-long) var(--timing);

            &:focus-visible {
              outline: 0;
            }

            &:hover {
              background: var(--color-hover-alpha-subtle);
            }

            svg {
              flex-shrink: 0;
              width: var(--size-40);
              height: var(--size-40);
            }
          }

          [data-issues-count-animation] {
            display: grid;
            place-items: center center;
            font-variant-numeric: tabular-nums;

            &[data-animate='false'] {
              [data-issues-count-exit],
              [data-issues-count-enter] {
                animation-duration: 0ms;
              }
            }

            > * {
              grid-area: 1 / 1;
            }

            [data-issues-count-exit] {
              animation: fadeOut 300ms var(--timing) forwards;
            }

            [data-issues-count-enter] {
              animation: fadeIn 300ms var(--timing) forwards;
            }
          }

          [data-issues-count-plural] {
            display: inline-block;
            &[data-animate='true'] {
              animation: fadeIn 300ms var(--timing) forwards;
            }
          }

          .paused {
            stroke-dashoffset: 0;
          }

          @keyframes fadeIn {
            0% {
              opacity: 0;
              filter: blur(2px);
              transform: translateY(8px);
            }
            100% {
              opacity: 1;
              filter: blur(0px);
              transform: translateY(0);
            }
          }

          @keyframes fadeOut {
            0% {
              opacity: 1;
              filter: blur(0px);
              transform: translateY(0);
            }
            100% {
              opacity: 0;
              transform: translateY(-12px);
              filter: blur(2px);
            }
          }

          @media (prefers-reduced-motion) {
            [data-issues-count-exit],
            [data-issues-count-enter],
            [data-issues-count-plural] {
              animation-duration: 0ms !important;
            }
          }
        `}
      </style>
      <div
        data-next-badge
        data-error={hasError}
        data-error-expanded={isExpanded}
        data-status={hasError || isCacheBypassing ? Status.None : currentStatus}
        data-cache-bypassing={isCacheBypassing}
        data-animate={newErrorDetected}
        style={{ width }}
      >
        <div ref={ref}>
          {/* Children */}
          {!state.disableDevIndicator && (
            <button
              id="next-logo"
              ref={triggerRef}
              data-next-mark
              onClick={onTriggerClick}
              disabled={state.disableDevIndicator}
              aria-haspopup="menu"
              aria-expanded={isMenuOpen}
              aria-controls="nextjs-dev-tools-menu"
              aria-label={`${isMenuOpen ? 'Close' : 'Open'} Next.js Dev Tools`}
              data-nextjs-dev-tools-button
              style={{
                display:
                  showStatusIndicator && !hasError && !isCacheBypassing
                    ? 'none'
                    : 'flex',
              }}
              {...buttonProps}
            >
              <NextMark />
            </button>
          )}
          {isExpanded && (
            <>
              {/* Error badge has priority over cache indicator */}
              {(isErrorExpanded || state.disableDevIndicator) && (
                <div data-issues>
                  <button
                    data-issues-open
                    aria-label="Open issues overlay"
                    onClick={() => {
                      if (state.isErrorOverlayOpen) {
                        dispatch({
                          type: ACTION_ERROR_OVERLAY_CLOSE,
                        })
                        return
                      }
                      dispatch({ type: ACTION_ERROR_OVERLAY_OPEN })
                      setPanel(null)
                    }}
                  >
                    {state.disableDevIndicator && (
                      <div data-disabled-icon>
                        <Warning />
                      </div>
                    )}
                    <AnimateCount
                      // Used the key to force a re-render when the count changes.
                      key={totalErrorCount}
                      animate={newErrorDetected}
                      data-issues-count-animation
                    >
                      {totalErrorCount}
                    </AnimateCount>{' '}
                    <div>
                      Issue
                      {totalErrorCount > 1 && (
                        <span
                          aria-hidden
                          data-issues-count-plural
                          // This only needs to animate once the count changes from 1 -> 2,
                          // otherwise it should stay static between re-renders.
                          data-animate={
                            newErrorDetected && totalErrorCount === 2
                          }
                        >
                          s
                        </span>
                      )}
                    </div>
                  </button>
                  {!state.buildError && (
                    <button
                      data-issues-collapse
                      aria-label="Collapse issues badge"
                      onClick={() => {
                        if (state.disableDevIndicator) {
                          setDismissed(true)
                        } else {
                          setIsErrorExpanded(false)
                        }
                        // Move focus to the trigger to prevent having it stuck on this element
                        triggerRef.current?.focus()
                      }}
                    >
                      <Cross data-cross />
                    </button>
                  )}
                </div>
              )}
              {/* Cache bypass badge shown when cache is being bypassed */}
              {isCacheBypassing && !hasError && !state.disableDevIndicator && (
                <CacheBypassBadge
                  onTriggerClick={onTriggerClick}
                  triggerRef={triggerRef}
                />
              )}
              {/* Status indicator shown when no errors and no cache bypass */}
              {showStatusIndicator &&
                !hasError &&
                !isCacheBypassing &&
                !state.disableDevIndicator && (
                  <StatusIndicator
                    status={displayStatus}
                    onClick={onTriggerClick}
                  />
                )}
            </>
          )}
        </div>
      </div>
      <div aria-hidden data-dot />
    </div>
  )
}

function AnimateCount({
  children: count,
  animate = true,
  ...props
}: {
  children: number
  animate: boolean
}) {
  return (
    <div {...props} data-animate={animate}>
      <div aria-hidden data-issues-count-exit>
        {count - 1}
      </div>
      <div data-issues-count data-issues-count-enter>
        {count}
      </div>
    </div>
  )
}

function CacheBypassBadge({
  onTriggerClick,
  triggerRef,
}: {
  onTriggerClick: () => void
  triggerRef: React.RefObject<HTMLButtonElement | null>
}) {
  const [dismissed, setDismissed] = useState(false)

  if (dismissed) {
    return null
  }

  return (
    <div data-issues data-cache-bypass-badge>
      <button
        data-issues-open
        data-nextjs-dev-tools-button
        aria-label="Open Next.js Dev Tools"
        onClick={onTriggerClick}
      >
        Cache disabled
      </button>
      <button
        data-issues-collapse
        aria-label="Collapse cache bypass badge"
        onClick={() => {
          setDismissed(true)
          // Move focus to the trigger to prevent having it stuck on this element
          triggerRef.current?.focus()
        }}
      >
        <Cross data-cross />
      </button>
    </div>
  )
}

function NextMark() {
  return (
    <svg width="40" height="40" viewBox="0 0 40 40" fill="none">
      <g transform="translate(8.5, 13)">
        <path
          className="paused"
          d="M13.3 15.2 L2.34 1 V12.6"
          fill="none"
          stroke="url(#next_logo_paint0_linear_1357_10853)"
          strokeWidth="1.86"
          mask="url(#next_logo_mask0)"
          strokeDasharray="29.6"
          strokeDashoffset="29.6"
        />
        <path
          className="paused"
          d="M11.825 1.5 V13.1"
          strokeWidth="1.86"
          stroke="url(#next_logo_paint1_linear_1357_10853)"
          strokeDasharray="11.6"
          strokeDashoffset="11.6"
        />
      </g>
      <defs>
        <linearGradient
          id="next_logo_paint0_linear_1357_10853"
          x1="9.95555"
          y1="11.1226"
          x2="15.4778"
          y2="17.9671"
          gradientUnits="userSpaceOnUse"
        >
          <stop stopColor="white" />
          <stop offset="0.604072" stopColor="white" stopOpacity="0" />
          <stop offset="1" stopColor="white" stopOpacity="0" />
        </linearGradient>
        <linearGradient
          id="next_logo_paint1_linear_1357_10853"
          x1="11.8222"
          y1="1.40039"
          x2="11.791"
          y2="9.62542"
          gradientUnits="userSpaceOnUse"
        >
          <stop stopColor="white" />
          <stop offset="1" stopColor="white" stopOpacity="0" />
        </linearGradient>
        <mask id="next_logo_mask0">
          <rect width="100%" height="100%" fill="white" />
          <rect width="5" height="1.5" fill="black" />
        </mask>
      </defs>
    </svg>
  )
}
Quest for Codev2.0.0
/
SIGN IN