Files
supabase__supabase/packages/dev-tools/DevToolbarTrigger.tsx
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

258 lines
8.6 KiB
TypeScript

'use client'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties, PointerEvent } from 'react'
import { Button, cn } from 'ui'
import { useDevToolbar } from './DevToolbarContext'
import { getEventCountBadge } from './utils'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position'
const DRAG_THRESHOLD = 4
const MARGIN = 24
const BUTTON_SIZE = 40 // h-10 w-10
// Spring easing: slight overshoot then settle
const SNAP_TRANSITION =
'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)'
type SnapPosition =
| 'top-left'
| 'top-center'
| 'top-right'
| 'middle-left'
| 'middle-center'
| 'middle-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
// All positions expressed as pixel top+left so transitions interpolate cleanly
function getSnapCoords(
position: SnapPosition,
vw: number,
vh: number
): { top: number; left: number } {
const [row, col] = position.split('-')
const top =
row === 'top'
? MARGIN
: row === 'bottom'
? vh - MARGIN - BUTTON_SIZE
: Math.round(vh / 2 - BUTTON_SIZE / 2)
const left =
col === 'left'
? MARGIN
: col === 'right'
? vw - MARGIN - BUTTON_SIZE
: Math.round(vw / 2 - BUTTON_SIZE / 2)
return { top, left }
}
function getNearestSnapPosition(cx: number, cy: number): SnapPosition {
const vw = window.innerWidth
const vh = window.innerHeight
const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle'
const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center'
return `${row}-${col}` as SnapPosition
}
function readStoredPosition(): SnapPosition {
if (typeof window === 'undefined') return 'bottom-right'
return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right'
}
export function DevToolbarTrigger() {
const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar()
const [snapPosition, setSnapPosition] = useState<SnapPosition>('bottom-right')
const [hasHydrated, setHasHydrated] = useState(false)
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null)
// Holds the last drag pixel position for one RAF to prime the CSS transition
const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null)
const [viewport, setViewport] = useState(() => ({
w: typeof window !== 'undefined' ? window.innerWidth : 1920,
h: typeof window !== 'undefined' ? window.innerHeight : 1080,
}))
const dragRef = useRef<{
startPointerX: number
startPointerY: number
startButtonX: number
startButtonY: number
hasDragged: boolean
} | null>(null)
const wasDraggingRef = useRef(false)
// Restore persisted position after mount to avoid SSR hydration mismatch.
// hasHydrated is set via RAF so the correct position is painted before transitions are enabled,
// preventing the spring animation from firing on initial load.
useEffect(() => {
const stored = readStoredPosition()
if (stored !== 'bottom-right') setSnapPosition(stored)
const id = requestAnimationFrame(() => setHasHydrated(true))
return () => cancelAnimationFrame(id)
}, [])
// Keep snap coords accurate on resize
useEffect(() => {
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight })
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// Two-phase snap: hold last drag position for one frame (primes the transition),
// then clear it so the spring fires from that position to the snap target
useEffect(() => {
if (releasedAt === null) return
const id = requestAnimationFrame(() => setReleasedAt(null))
return () => cancelAnimationFrame(id)
}, [releasedAt])
const handlePointerDown = useCallback((e: PointerEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
dragRef.current = {
startPointerX: e.clientX,
startPointerY: e.clientY,
startButtonX: rect.left,
startButtonY: rect.top,
hasDragged: false,
}
wasDraggingRef.current = false
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback((e: PointerEvent<HTMLButtonElement>) => {
if (!dragRef.current) return
const dx = e.clientX - dragRef.current.startPointerX
const dy = e.clientY - dragRef.current.startPointerY
if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
dragRef.current.hasDragged = true
setDragPos({
x: dragRef.current.startButtonX + dx,
y: dragRef.current.startButtonY + dy,
})
}, [])
const handlePointerUp = useCallback((e: PointerEvent<HTMLButtonElement>) => {
if (!dragRef.current) return
const { hasDragged } = dragRef.current
dragRef.current = null
wasDraggingRef.current = hasDragged
if (!hasDragged) return
const rect = e.currentTarget.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const newPosition = getNearestSnapPosition(cx, cy)
setSnapPosition(newPosition)
localStorage.setItem(POSITION_STORAGE_KEY, newPosition)
// Phase 1: park at last drag position with transition primed
setReleasedAt({ x: rect.left, y: rect.top })
setDragPos(null)
}, [])
const handlePointerCancel = useCallback(() => {
dragRef.current = null
wasDraggingRef.current = false
setDragPos(null)
setReleasedAt(null)
}, [])
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
const eventCountBadge = getEventCountBadge(events.length)
const isDragging = dragPos !== null
const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
const containerStyle: CSSProperties =
dragPos !== null
? {
position: 'fixed',
zIndex: 50,
left: dragPos.x,
top: dragPos.y,
transition: 'none',
opacity: 1,
}
: releasedAt !== null
? // Phase 1: same pixel position as drag end, transition now defined
{
position: 'fixed',
zIndex: 50,
left: releasedAt.x,
top: releasedAt.y,
transition: FULL_TRANSITION,
opacity: isOpen ? 0 : 1,
pointerEvents: isOpen ? 'none' : undefined,
}
: // Phase 2: spring fires from releasedAt → snapCoords
{
position: 'fixed',
zIndex: 50,
...snapCoords,
transition: hasHydrated ? FULL_TRANSITION : 'none',
opacity: isOpen ? 0 : 1,
pointerEvents: isOpen ? 'none' : undefined,
}
const handleClick = () => {
if (wasDraggingRef.current) {
wasDraggingRef.current = false
return
}
setIsOpen(true)
}
return (
<div style={containerStyle}>
<Button
rounded
className={cn(
'relative h-10 w-10 p-0 shadow-md',
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
'select-none touch-none',
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
)}
aria-label="Open dev toolbar"
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
title="Dev Toolbar"
>
<Image
src="/img/logo-pixel-small-light.png"
alt="Dev Toolbar"
width={16}
height={16}
style={{
filter:
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
}}
aria-hidden="true"
className="pointer-events-none"
/>
{eventCountBadge && (
<span
className={cn(
'absolute -top-1 -right-1',
'inline-flex items-center justify-center',
'rounded-full bg-brand text-black',
'text-[9px] font-medium leading-none tracking-tighter tabular-nums',
eventCountBadge.sizeClass
)}
>
{eventCountBadge.label}
</span>
)}
</Button>
</div>
)
}