Skip to content

Commit

Permalink
[DevTools] Add CMD + . keyboard shortcut to show/hide (#74878)
Browse files Browse the repository at this point in the history
  • Loading branch information
devjiwonchoi and huozhi authored Jan 14, 2025
1 parent 6616e20 commit aa0a819
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,7 @@ const DevToolsPopover = ({

<IndicatorRow
label="Hide Dev Tools"
value={
// TODO: replace with cmd+.for mac, ctrl+. for windows & implement hiding + unhiding logic
null
}
value={<DevToolsShortcutGroup />}
onClick={hide}
/>
<IndicatorRow
Expand Down Expand Up @@ -180,3 +177,38 @@ const IssueCount = ({ count }: { count: number }) => {
</span>
)
}

function DevToolsShortcutGroup() {
const isMac =
// Feature detect for `navigator.userAgentData` which is experimental:
// https://developer.mozilla.org/en-US/docs/Web/API/NavigatorUAData/platform
'userAgentData' in navigator
? (navigator.userAgentData as any).platform === 'macOS'
: // This is the least-bad option to detect the modifier key when using `navigator.platform`:
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/platform#examples
navigator.platform.indexOf('Mac') === 0 ||
navigator.platform === 'iPhone'

return (
<span data-nextjs-dev-tools-shortcut-group>
{isMac ? <CmdIcon /> : <CtrlIcon />}
<DotIcon />
</span>
)
}

function CmdIcon() {
return <span data-nextjs-dev-tools-icon></span>
}

function CtrlIcon() {
return (
<span data-nextjs-dev-tools-icon data-nextjs-dev-tools-ctrl-icon>
ctrl
</span>
)
}

function DotIcon() {
return <span data-nextjs-dev-tools-icon>.</span>
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,33 @@ export const styles = css`
align-items: flex-start;
background: var(--color-background-100);
}
[data-nextjs-dev-tools-shortcut-group] {
display: flex;
align-items: flex-start;
gap: var(--size-1);
}
[data-nextjs-dev-tools-icon] {
display: flex;
min-width: var(--size-5);
height: var(--size-5);
padding: var(--size-1) var(--size-1_5);
justify-content: center;
align-items: center;
border-radius: var(--rounded-md);
border: 1px solid var(--color-gray-alpha-400);
background: var(--color-background-100);
color: var(--color-gray-1000);
text-align: center;
font-size: var(--size-font-smaller);
font-style: normal;
font-weight: 400;
line-height: var(--size-4);
}
[data-nextjs-dev-tools-ctrl-icon] {
width: 100%;
}
`
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
import { extractNextErrorCode } from '../../../../../../lib/error-telemetry-utils'
import { DevToolsIndicator } from '../components/Errors/dev-tools-indicator/dev-tools-indicator'
import { ErrorOverlayLayout } from '../components/Errors/error-overlay-layout/error-overlay-layout'
import { useKeyboardShortcut } from '../hooks/use-keyboard-shortcut'
import { MODIFIERS } from '../hooks/use-keyboard-shortcut'

export type SupportedErrorEvent = {
id: number
Expand Down Expand Up @@ -200,6 +202,15 @@ export function Errors({
const hide = useCallback(() => setDisplayState('hidden'), [])
const fullscreen = useCallback(() => setDisplayState('fullscreen'), [])

// Register `(cmd|ctrl) + .` to show/hide the error indicator.
useKeyboardShortcut({
key: '.',
modifiers: [MODIFIERS.CTRL_CMD],
callback: () => {
setDisplayState((prev) => (prev === 'hidden' ? 'minimized' : 'hidden'))
},
})

if (displayState === 'hidden') {
return null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* @jest-environment jsdom
*/
/* eslint-disable import/no-extraneous-dependencies */
import { renderHook } from '@testing-library/react'
import { useKeyboardShortcut } from './use-keyboard-shortcut'
import { MODIFIERS } from './use-keyboard-shortcut'

describe('useKeyboardShortcut', () => {
let addEventListenerSpy: jest.SpyInstance
let removeEventListenerSpy: jest.SpyInstance

beforeEach(() => {
addEventListenerSpy = jest.spyOn(window, 'addEventListener')
removeEventListenerSpy = jest.spyOn(window, 'removeEventListener')
})

afterEach(() => {
jest.clearAllMocks()
})

it('should add and remove event listener', () => {
const callback = jest.fn()
const { unmount } = renderHook(() =>
useKeyboardShortcut({
key: 'k',
callback,
modifiers: [MODIFIERS.CTRL_CMD],
})
)

// When used `expect.any(Function)`, received:
// error TS21228: [ban-function-calls] Constructing functions from strings can lead to XSS.
const eventListener = addEventListenerSpy.mock.calls[0][1]
expect(typeof eventListener).toBe('function')
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', eventListener)

unmount()
expect(removeEventListenerSpy).toHaveBeenCalled()
})

it('should trigger callback when correct key and modifier are pressed', () => {
const callback = jest.fn()
renderHook(() =>
useKeyboardShortcut({
key: 'k',
callback,
modifiers: [MODIFIERS.CTRL_CMD],
})
)

// Simulate keydown event with Cmd/Ctrl + K
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
})
)

expect(callback).toHaveBeenCalledTimes(1)
})

it('should work with multiple modifiers', () => {
const callback = jest.fn()
renderHook(() =>
useKeyboardShortcut({
key: 'k',
callback,
modifiers: [MODIFIERS.CTRL_CMD, MODIFIERS.SHIFT],
})
)

// Should not trigger with just Cmd/Ctrl
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
})
)
expect(callback).not.toHaveBeenCalled()

// Should trigger with Cmd/Ctrl + Shift
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
shiftKey: true,
})
)
expect(callback).toHaveBeenCalledTimes(1)
})

it('should be case insensitive', () => {
const callback = jest.fn()
renderHook(() =>
useKeyboardShortcut({
key: 'k',
callback,
modifiers: [MODIFIERS.CTRL_CMD],
})
)

window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'K', // uppercase
metaKey: true,
})
)

expect(callback).toHaveBeenCalledTimes(1)
})

it('should prevent default event behavior', () => {
const callback = jest.fn()
renderHook(() =>
useKeyboardShortcut({
key: 'k',
callback,
modifiers: [MODIFIERS.CTRL_CMD],
})
)

const event = new KeyboardEvent('keydown', {
key: 'k',
metaKey: true,
})
const preventDefaultSpy = jest.spyOn(event, 'preventDefault')

window.dispatchEvent(event)

expect(preventDefaultSpy).toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useEffect } from 'react'

export const MODIFIERS = {
CTRL_CMD: 'CTRL_CMD',
ALT: 'ALT',
SHIFT: 'SHIFT',
} as const

type KeyboardShortcutProps = {
key: string
callback: () => void
modifiers: (typeof MODIFIERS)[keyof typeof MODIFIERS][]
}

export function useKeyboardShortcut({
key,
callback,
modifiers,
}: KeyboardShortcutProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const modifiersPressed = modifiers.every((modifier) => {
switch (modifier) {
case MODIFIERS.CTRL_CMD:
return e.metaKey || e.ctrlKey
case MODIFIERS.ALT:
return e.altKey
case MODIFIERS.SHIFT:
return e.shiftKey
default:
return false
}
})

if (modifiersPressed && e.key.toLowerCase() === key.toLowerCase()) {
e.preventDefault()
callback()
}
}

window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [key, callback, modifiers])
}

0 comments on commit aa0a819

Please sign in to comment.