diff --git a/.changeset/dirty-berries-mate.md b/.changeset/dirty-berries-mate.md new file mode 100644 index 00000000..9d857060 --- /dev/null +++ b/.changeset/dirty-berries-mate.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-hotkeys-devtools': patch +'@tanstack/preact-hotkeys-devtools': patch +'@tanstack/solid-hotkeys-devtools': patch +--- + +Allow standalone HotkeysDevtoolsPanel components without props, supplying dark theme and open defaults while preserving props from the Devtools dock. diff --git a/docs/devtools.md b/docs/devtools.md index 2a3aabb3..ece7427e 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -97,6 +97,20 @@ import { HotkeysDevtoolsPanel } from '@tanstack/vue-hotkeys-devtools' For React, Preact, and Solid, the Hotkeys panel appears alongside any other TanStack devtools plugins you have installed. +### Standalone panel + +React, Preact, and Solid also export `HotkeysDevtoolsPanel` for rendering without the TanStack Devtools dock: + +```tsx +import { HotkeysDevtoolsPanel } from '@tanstack/react-hotkeys-devtools' + +function DebugPanel() { + return +} +``` + +Use the corresponding `@tanstack/preact-hotkeys-devtools` or `@tanstack/solid-hotkeys-devtools` import for those frameworks. The panel and no-op component also accept an omitted props argument during rendering. Both props are optional: `theme` defaults to `'dark'` and `devtoolsOpen` defaults to `true`. Pass `theme="light"` to select the light theme. When using `hotkeysDevtoolsPlugin()`, the dock supplies these props and its values take precedence over the standalone defaults. + ## Production builds In production builds, the framework devtools adapters return no-op implementations, so they don't affect your bundle's behavior. diff --git a/packages/preact-hotkeys-devtools/src/PreactHotkeysDevtools.tsx b/packages/preact-hotkeys-devtools/src/PreactHotkeysDevtools.tsx index 39708a03..935babad 100644 --- a/packages/preact-hotkeys-devtools/src/PreactHotkeysDevtools.tsx +++ b/packages/preact-hotkeys-devtools/src/PreactHotkeysDevtools.tsx @@ -1,15 +1,27 @@ +import { h } from 'preact' import { createPreactPanel } from '@tanstack/devtools-utils/preact' import { HotkeysDevtoolsCore } from '@tanstack/hotkeys-devtools' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/preact' import type { JSX } from 'preact' -export interface HotkeysDevtoolsPreactInit extends DevtoolsPanelProps {} +export interface HotkeysDevtoolsPreactInit extends Partial {} type HotkeysDevtoolsPanelComponent = ( - props: HotkeysDevtoolsPreactInit, + props?: HotkeysDevtoolsPreactInit, ) => JSX.Element const panels = createPreactPanel(HotkeysDevtoolsCore) -export const HotkeysDevtoolsPanel: HotkeysDevtoolsPanelComponent = panels[0] -export const HotkeysDevtoolsPanelNoOp: HotkeysDevtoolsPanelComponent = panels[1] +function withDefaults( + Panel: (typeof panels)[number], +): HotkeysDevtoolsPanelComponent { + return (props = {}) => + h(Panel, { + ...props, + theme: props.theme ?? 'dark', + devtoolsOpen: props.devtoolsOpen ?? true, + }) +} + +export const HotkeysDevtoolsPanel = withDefaults(panels[0]) +export const HotkeysDevtoolsPanelNoOp = withDefaults(panels[1]) diff --git a/packages/preact-hotkeys-devtools/tests/panel.test.ts b/packages/preact-hotkeys-devtools/tests/panel.test.ts new file mode 100644 index 00000000..977e5cbd --- /dev/null +++ b/packages/preact-hotkeys-devtools/tests/panel.test.ts @@ -0,0 +1,67 @@ +import { h, render } from 'preact' +import { act } from 'preact/test-utils' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + HotkeysDevtoolsPanel, + HotkeysDevtoolsPanelNoOp, +} from '../src/PreactHotkeysDevtools' +import { hotkeysDevtoolsPlugin } from '../src/plugin' + +const core = vi.hoisted(() => ({ mount: vi.fn(), unmount: vi.fn() })) +vi.mock('@tanstack/hotkeys-devtools', () => ({ + HotkeysDevtoolsCore: class { + mount = core.mount + unmount = core.unmount + }, +})) + +const emptyProps: NonNullable[0]> = {} +let host: HTMLDivElement +beforeEach(() => { + vi.clearAllMocks() + host = document.createElement('div') + document.body.append(host) +}) +afterEach(() => { + act(() => render(null, host)) + host.remove() +}) + +it('mounts a standalone panel with default props', () => { + act(() => render(h(HotkeysDevtoolsPanel, emptyProps), host)) + expect(core.mount).toHaveBeenCalledOnce() + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'dark', + devtoolsOpen: true, + }) + act(() => render(null, host)) + expect(core.unmount).toHaveBeenCalledOnce() +}) + +it('preserves props supplied by the devtools dock', () => { + const plugin = hotkeysDevtoolsPlugin() + act(() => + render(plugin.render(host, { theme: 'light', devtoolsOpen: false }), host), + ) + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'light', + devtoolsOpen: false, + }) +}) + +it('keeps the no-op panel inert with empty props', () => { + act(() => render(h(HotkeysDevtoolsPanelNoOp, emptyProps), host)) + expect(core.mount).not.toHaveBeenCalled() +}) + +it('accepts omitted props for real and no-op panels during render', () => { + const Standalone = () => HotkeysDevtoolsPanel() + const NoOp = () => HotkeysDevtoolsPanelNoOp() + act(() => render(h(Standalone, {}), host)) + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'dark', + devtoolsOpen: true, + }) + act(() => render(h(NoOp, {}), host)) + expect(core.mount).toHaveBeenCalledOnce() +}) diff --git a/packages/react-hotkeys-devtools/src/ReactHotkeysDevtools.tsx b/packages/react-hotkeys-devtools/src/ReactHotkeysDevtools.tsx index 33c7fb53..0f87279f 100644 --- a/packages/react-hotkeys-devtools/src/ReactHotkeysDevtools.tsx +++ b/packages/react-hotkeys-devtools/src/ReactHotkeysDevtools.tsx @@ -1,15 +1,27 @@ +import { createElement } from 'react' import { createReactPanel } from '@tanstack/devtools-utils/react' import { HotkeysDevtoolsCore } from '@tanstack/hotkeys-devtools' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react' import type { JSX } from 'react' -export interface HotkeysDevtoolsReactInit extends DevtoolsPanelProps {} +export interface HotkeysDevtoolsReactInit extends Partial {} type HotkeysDevtoolsPanelComponent = ( - props: HotkeysDevtoolsReactInit, + props?: HotkeysDevtoolsReactInit, ) => JSX.Element const panels = createReactPanel(HotkeysDevtoolsCore) -export const HotkeysDevtoolsPanel: HotkeysDevtoolsPanelComponent = panels[0] -export const HotkeysDevtoolsPanelNoOp: HotkeysDevtoolsPanelComponent = panels[1] +function withDefaults( + Panel: (typeof panels)[number], +): HotkeysDevtoolsPanelComponent { + return (props = {}) => + createElement(Panel, { + ...props, + theme: props.theme ?? 'dark', + devtoolsOpen: props.devtoolsOpen ?? true, + }) +} + +export const HotkeysDevtoolsPanel = withDefaults(panels[0]) +export const HotkeysDevtoolsPanelNoOp = withDefaults(panels[1]) diff --git a/packages/react-hotkeys-devtools/tests/panel.test.ts b/packages/react-hotkeys-devtools/tests/panel.test.ts new file mode 100644 index 00000000..ae3176a9 --- /dev/null +++ b/packages/react-hotkeys-devtools/tests/panel.test.ts @@ -0,0 +1,75 @@ +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + HotkeysDevtoolsPanel, + HotkeysDevtoolsPanelNoOp, +} from '../src/ReactHotkeysDevtools' +import { hotkeysDevtoolsPlugin } from '../src/plugin' +import type { Root } from 'react-dom/client' + +const core = vi.hoisted(() => ({ mount: vi.fn(), unmount: vi.fn() })) +vi.mock('@tanstack/hotkeys-devtools', () => ({ + HotkeysDevtoolsCore: class { + mount = core.mount + unmount = core.unmount + }, +})) + +const emptyProps = {} satisfies NonNullable< + Parameters[0] +> +let host: HTMLDivElement +let root: Root +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.clearAllMocks() + host = document.createElement('div') + document.body.append(host) + root = createRoot(host) +}) +afterEach(async () => { + await act(() => root.unmount()) + host.remove() + vi.unstubAllGlobals() +}) + +it('mounts a standalone panel with default props', async () => { + await act(() => root.render(createElement(HotkeysDevtoolsPanel, emptyProps))) + expect(core.mount).toHaveBeenCalledOnce() + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'dark', + devtoolsOpen: true, + }) + await act(() => root.render(null)) +}) + +it('preserves props supplied by the devtools dock', async () => { + const plugin = hotkeysDevtoolsPlugin() + await act(() => + root.render(plugin.render(host, { theme: 'light', devtoolsOpen: false })), + ) + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'light', + devtoolsOpen: false, + }) +}) + +it('keeps the no-op panel inert with empty props', async () => { + await act(() => + root.render(createElement(HotkeysDevtoolsPanelNoOp, emptyProps)), + ) + expect(core.mount).not.toHaveBeenCalled() +}) + +it('accepts omitted props for real and no-op panels during render', async () => { + const Standalone = () => HotkeysDevtoolsPanel() + const NoOp = () => HotkeysDevtoolsPanelNoOp() + await act(() => root.render(createElement(Standalone))) + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'dark', + devtoolsOpen: true, + }) + await act(() => root.render(createElement(NoOp))) + expect(core.mount).toHaveBeenCalledOnce() +}) diff --git a/packages/solid-hotkeys-devtools/src/SolidHotkeysDevtools.tsx b/packages/solid-hotkeys-devtools/src/SolidHotkeysDevtools.tsx index 60d152e1..4c6972fb 100644 --- a/packages/solid-hotkeys-devtools/src/SolidHotkeysDevtools.tsx +++ b/packages/solid-hotkeys-devtools/src/SolidHotkeysDevtools.tsx @@ -1,15 +1,26 @@ +import { createComponent, mergeProps } from 'solid-js' import { createSolidPanel } from '@tanstack/devtools-utils/solid' import { HotkeysDevtoolsCore } from '@tanstack/hotkeys-devtools' import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/solid' import type { JSX } from 'solid-js' -export interface HotkeysDevtoolsSolidInit extends DevtoolsPanelProps {} +export interface HotkeysDevtoolsSolidInit extends Partial {} type HotkeysDevtoolsPanelComponent = ( - props: HotkeysDevtoolsSolidInit, + props?: HotkeysDevtoolsSolidInit, ) => JSX.Element const panels = createSolidPanel(HotkeysDevtoolsCore) -export const HotkeysDevtoolsPanel: HotkeysDevtoolsPanelComponent = panels[0] -export const HotkeysDevtoolsPanelNoOp: HotkeysDevtoolsPanelComponent = panels[1] +function withDefaults( + Panel: (typeof panels)[number], +): HotkeysDevtoolsPanelComponent { + return (props = {}) => + createComponent( + Panel, + mergeProps({ theme: 'dark' as const, devtoolsOpen: true }, props), + ) +} + +export const HotkeysDevtoolsPanel = withDefaults(panels[0]) +export const HotkeysDevtoolsPanelNoOp = withDefaults(panels[1]) diff --git a/packages/solid-hotkeys-devtools/tests/panel.test.ts b/packages/solid-hotkeys-devtools/tests/panel.test.ts new file mode 100644 index 00000000..94713487 --- /dev/null +++ b/packages/solid-hotkeys-devtools/tests/panel.test.ts @@ -0,0 +1,82 @@ +import { createComponent, createSignal } from 'solid-js' +import { render } from 'solid-js/web' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + HotkeysDevtoolsPanel, + HotkeysDevtoolsPanelNoOp, +} from '../src/SolidHotkeysDevtools' +import { hotkeysDevtoolsPlugin } from '../src/plugin' + +const core = vi.hoisted(() => ({ mount: vi.fn(), unmount: vi.fn() })) +vi.mock('@tanstack/hotkeys-devtools', () => ({ + HotkeysDevtoolsCore: class { + mount = core.mount + unmount = core.unmount + }, +})) + +const emptyProps = {} satisfies NonNullable< + Parameters[0] +> +let host: HTMLDivElement +let dispose: () => void +beforeEach(() => { + vi.clearAllMocks() + host = document.createElement('div') + document.body.append(host) + dispose = () => {} +}) +afterEach(() => { + dispose() + host.remove() +}) + +it('mounts a standalone panel with default props', () => { + dispose = render( + () => createComponent(HotkeysDevtoolsPanel, emptyProps), + host, + ) + expect(core.mount).toHaveBeenCalledOnce() + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'dark', + devtoolsOpen: true, + }) +}) + +it('preserves reactive props supplied by the devtools dock', () => { + const [theme, setTheme] = createSignal<'light' | 'dark'>('light') + const plugin = hotkeysDevtoolsPlugin() + dispose = render( + () => + plugin.render(host, { + get theme() { + return theme() + }, + devtoolsOpen: false, + }), + host, + ) + const props = core.mount.mock.calls[0]![1] + expect(props).toMatchObject({ theme: 'light', devtoolsOpen: false }) + setTheme('dark') + expect(props.theme).toBe('dark') +}) + +it('keeps the no-op panel inert with empty props', () => { + dispose = render( + () => createComponent(HotkeysDevtoolsPanelNoOp, emptyProps), + host, + ) + expect(core.mount).not.toHaveBeenCalled() +}) + +it('accepts omitted props for real and no-op panels during render', () => { + dispose = render(() => HotkeysDevtoolsPanel(), host) + expect(core.mount.mock.calls[0]![1]).toMatchObject({ + theme: 'dark', + devtoolsOpen: true, + }) + dispose() + dispose = render(() => HotkeysDevtoolsPanelNoOp(), host) + expect(core.mount).toHaveBeenCalledOnce() +})