diff --git a/docs/devtools.md b/docs/devtools.md index 625e2e9156..db6b2e0f7b 100644 --- a/docs/devtools.md +++ b/docs/devtools.md @@ -61,6 +61,9 @@ function App() { - `position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"` - Defaults to `bottom-left` - The position of the React Query logo to open and close the devtools panel +- `panelPosition?: "top" | "bottom" | "left" | "right"` + - Defaults to `bottom` + - The position of the React Query devtools panel - `context?: React.Context` - Use this to use a custom React Query context. Otherwise, `defaultContext` will be used. @@ -89,6 +92,10 @@ Use these options to style the dev tools. - The standard React style object used to style a component with inline styles - `className: string` - The standard React className property used to style a component with classes +- `showCloseButton?: boolean` + - Show a close button inside the devtools panel +- `closeButtonProps: PropsObject` + - Use this to add props to the close button. For example, you can add `className`, `style` (merge and override default style), `onClick` (extend default handler), etc. ## Devtools in production @@ -103,30 +110,32 @@ import { Example } from './Example' const queryClient = new QueryClient() const ReactQueryDevtoolsProduction = React.lazy(() => - import('@tanstack/react-query-devtools/build/lib/index.prod.js').then(d => ({ - default: d.ReactQueryDevtools - })) + import('@tanstack/react-query-devtools/build/lib/index.prod.js').then( + (d) => ({ + default: d.ReactQueryDevtools, + }), + ), ) function App() { - const [showDevtools, setShowDevtools] = React.useState(false) - - React.useEffect(() => { - // @ts-ignore - window.toggleDevtools = () => setShowDevtools(old => !old) - }, []) - - return ( - - - - { showDevtools && ( - - - - )} - - ); + const [showDevtools, setShowDevtools] = React.useState(false) + + React.useEffect(() => { + // @ts-ignore + window.toggleDevtools = () => setShowDevtools((old) => !old) + }, []) + + return ( + + + + {showDevtools && ( + + + + )} + + ) } export default App @@ -140,9 +149,9 @@ If your bundler supports package exports, you can use the following import path: ```tsx const ReactQueryDevtoolsProduction = React.lazy(() => - import('@tanstack/react-query-devtools/production').then(d => ({ - default: d.ReactQueryDevtools - })) + import('@tanstack/react-query-devtools/production').then((d) => ({ + default: d.ReactQueryDevtools, + })), ) ``` diff --git a/packages/react-query-devtools/src/__tests__/devtools.test.tsx b/packages/react-query-devtools/src/__tests__/devtools.test.tsx index 4021d91cc2..543ffb4c59 100644 --- a/packages/react-query-devtools/src/__tests__/devtools.test.tsx +++ b/packages/react-query-devtools/src/__tests__/devtools.test.tsx @@ -764,4 +764,103 @@ describe('ReactQueryDevtools', () => { consoleErrorMock.mockRestore() }) }) + + it('should render a menu to select panel position', async () => { + const { queryClient } = createQueryClient() + + function Page() { + const { data = 'default' } = useQuery(['check'], async () => 'test') + + return ( +
+

{data}

+
+ ) + } + + renderWithClient(queryClient, , { + initialIsOpen: true, + }) + + const positionSelect = (await screen.findByLabelText( + 'Panel position', + )) as HTMLSelectElement + + expect(positionSelect.value).toBe('bottom') + }) + + it(`should render the panel to the left if panelPosition is set to 'left'`, async () => { + const { queryClient } = createQueryClient() + + function Page() { + const { data = 'default' } = useQuery(['check'], async () => 'test') + + return ( +
+

{data}

+
+ ) + } + + renderWithClient(queryClient, , { + initialIsOpen: true, + panelPosition: 'left', + }) + + const positionSelect = (await screen.findByLabelText( + 'Panel position', + )) as HTMLSelectElement + + expect(positionSelect.value).toBe('left') + + const panel = (await screen.getByLabelText( + 'React Query Devtools Panel', + )) as HTMLDivElement + + expect(panel.style.left).toBe('0px') + expect(panel.style.width).toBe('500px') + expect(panel.style.height).toBe('100vh') + }) + + it('should change the panel position if user select different option from the menu', async () => { + const { queryClient } = createQueryClient() + + function Page() { + const { data = 'default' } = useQuery(['check'], async () => 'test') + + return ( +
+

{data}

+
+ ) + } + + renderWithClient(queryClient, , { + initialIsOpen: true, + }) + + const positionSelect = (await screen.findByLabelText( + 'Panel position', + )) as HTMLSelectElement + + expect(positionSelect.value).toBe('bottom') + + const panel = (await screen.getByLabelText( + 'React Query Devtools Panel', + )) as HTMLDivElement + + expect(panel.style.bottom).toBe('0px') + expect(panel.style.height).toBe('500px') + expect(panel.style.width).toBe('100%') + + await act(async () => { + fireEvent.change(positionSelect, { target: { value: 'right' } }) + }) + + expect(positionSelect.value).toBe('right') + + expect(panel.style.right).toBe('0px') + expect(panel.style.width).toBe('500px') + expect(panel.style.height).toBe('100vh') + }) }) diff --git a/packages/react-query-devtools/src/devtools.tsx b/packages/react-query-devtools/src/devtools.tsx index f5f9a7928c..a9246fb3a3 100644 --- a/packages/react-query-devtools/src/devtools.tsx +++ b/packages/react-query-devtools/src/devtools.tsx @@ -13,9 +13,17 @@ import { } from '@tanstack/react-query' import { rankItem } from '@tanstack/match-sorter-utils' import useLocalStorage from './useLocalStorage' -import { sortFns, useIsMounted } from './utils' -import ScreenReader from './screenreader' - +import { + isVerticalSide, + sortFns, + useIsMounted, + getSidePanelStyle, + minPanelSize, + getResizeHandleStyle, + getSidedProp, + defaultPanelSize, +} from './utils' +import type { Corner, Side } from './utils' import { Panel, QueryKeys, @@ -26,6 +34,7 @@ import { Select, ActiveQueryPanel, } from './styledComponents' +import ScreenReader from './screenreader' import { ThemeProvider, defaultTheme as theme } from './theme' import { getQueryStatusLabel, getQueryStatusColor } from './utils' import Explorer from './Explorer' @@ -39,29 +48,25 @@ export interface DevtoolsOptions extends ContextOptions { /** * Use this to add props to the panel. For example, you can add className, style (merge and override default style), etc. */ - panelProps?: React.DetailedHTMLProps< - React.HTMLAttributes, - HTMLDivElement - > + panelProps?: React.ComponentPropsWithoutRef<'div'> /** * Use this to add props to the close button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc. */ - closeButtonProps?: React.DetailedHTMLProps< - React.ButtonHTMLAttributes, - HTMLButtonElement - > + closeButtonProps?: React.ComponentPropsWithoutRef<'button'> /** * Use this to add props to the toggle button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc. */ - toggleButtonProps?: React.DetailedHTMLProps< - React.ButtonHTMLAttributes, - HTMLButtonElement - > + toggleButtonProps?: React.ComponentPropsWithoutRef<'button'> /** * The position of the React Query logo to open and close the devtools panel. * Defaults to 'bottom-left'. */ - position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' + position?: Corner + /** + * The position of the React Query devtools panel. + * Defaults to 'bottom'. + */ + panelPosition?: Side /** * Use this to render the devtools inside a different type of container element for a11y purposes. * Any string which corresponds to a valid intrinsic JSX element is allowed. @@ -98,7 +103,24 @@ interface DevtoolsPanelOptions extends ContextOptions { /** * Handles the opening and closing the devtools panel */ - handleDragStart: (e: React.MouseEvent) => void + onDragStart: (e: React.MouseEvent) => void + /** + * The position of the React Query devtools panel. + * Defaults to 'bottom'. + */ + position?: Side + /** + * Handles the panel position select change + */ + onPositionChange?: (side: Side) => void + /** + * Show a close button inside the panel + */ + showCloseButton?: boolean + /** + * Use this to add props to the close button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc. + */ + closeButtonProps?: React.ComponentPropsWithoutRef<'button'> } export function ReactQueryDevtools({ @@ -110,6 +132,7 @@ export function ReactQueryDevtools({ containerElement: Container = 'aside', context, styleNonce, + panelPosition: initialPanelPosition = 'bottom', }: DevtoolsOptions): React.ReactElement | null { const rootRef = React.useRef(null) const panelRef = React.useRef(null) @@ -117,10 +140,20 @@ export function ReactQueryDevtools({ 'reactQueryDevtoolsOpen', initialIsOpen, ) - const [devtoolsHeight, setDevtoolsHeight] = useLocalStorage( + const [devtoolsHeight, setDevtoolsHeight] = useLocalStorage( 'reactQueryDevtoolsHeight', - null, + defaultPanelSize, + ) + const [devtoolsWidth, setDevtoolsWidth] = useLocalStorage( + 'reactQueryDevtoolsWidth', + defaultPanelSize, + ) + + const [panelPosition = 'bottom', setPanelPosition] = useLocalStorage( + 'reactQueryDevtoolsPanelPosition', + initialPanelPosition, ) + const [isResolvedOpen, setIsResolvedOpen] = React.useState(false) const [isResizing, setIsResizing] = React.useState(false) const isMounted = useIsMounted() @@ -129,22 +162,39 @@ export function ReactQueryDevtools({ panelElement: HTMLDivElement | null, startEvent: React.MouseEvent, ) => { + if (!panelElement) return if (startEvent.button !== 0) return // Only allow left click for drag - + const isVertical = isVerticalSide(panelPosition) setIsResizing(true) - const dragInfo = { - originalHeight: panelElement?.getBoundingClientRect().height ?? 0, - pageY: startEvent.pageY, - } + const { height, width } = panelElement.getBoundingClientRect() + const startX = startEvent.clientX + const startY = startEvent.clientY + let newSize = 0 const run = (moveEvent: MouseEvent) => { - const delta = dragInfo.pageY - moveEvent.pageY - const newHeight = dragInfo.originalHeight + delta - - setDevtoolsHeight(newHeight) + // prevent mouse selecting stuff with mouse drag + moveEvent.preventDefault() + + // calculate the correct size based on mouse position and current panel position + // hint: it is different formula for the opposite sides + if (isVertical) { + newSize = + width + + (panelPosition === 'right' + ? startX - moveEvent.clientX + : moveEvent.clientX - startX) + setDevtoolsWidth(newSize) + } else { + newSize = + height + + (panelPosition === 'bottom' + ? startY - moveEvent.clientY + : moveEvent.clientY - startY) + setDevtoolsHeight(newSize) + } - if (newHeight < 70) { + if (newSize < minPanelSize) { setIsOpen(false) } else { setIsOpen(true) @@ -152,13 +202,16 @@ export function ReactQueryDevtools({ } const unsub = () => { - setIsResizing(false) - document.removeEventListener('mousemove', run) - document.removeEventListener('mouseUp', unsub) + if (isResizing) { + setIsResizing(false) + } + + document.removeEventListener('mousemove', run, false) + document.removeEventListener('mouseUp', unsub, false) } - document.addEventListener('mousemove', run) - document.addEventListener('mouseup', unsub) + document.addEventListener('mousemove', run, false) + document.addEventListener('mouseup', unsub, false) } React.useEffect(() => { @@ -195,12 +248,23 @@ export function ReactQueryDevtools({ React.useEffect(() => { if (isResolvedOpen) { const root = rootRef.current - const previousValue = root?.parentElement?.style.paddingBottom + const styleProp = getSidedProp('padding', panelPosition) + const isVertical = isVerticalSide(panelPosition) + const previousValue = root?.parentElement?.style[styleProp] const run = () => { - const containerHeight = panelRef.current?.getBoundingClientRect().height if (root?.parentElement) { - root.parentElement.style.paddingBottom = `${containerHeight}px` + // reset the padding + root.parentElement.style.padding = '0px' + root.parentElement.style.paddingTop = '0px' + root.parentElement.style.paddingBottom = '0px' + root.parentElement.style.paddingLeft = '0px' + root.parentElement.style.paddingRight = '0px' + // set the new padding based on the new panel position + + root.parentElement.style[styleProp] = `${ + isVertical ? devtoolsWidth : devtoolsHeight + }px` } } @@ -212,27 +276,32 @@ export function ReactQueryDevtools({ return () => { window.removeEventListener('resize', run) if (root?.parentElement && typeof previousValue === 'string') { - root.parentElement.style.paddingBottom = previousValue + root.parentElement.style[styleProp] = previousValue } } } } - }, [isResolvedOpen]) + }, [isResolvedOpen, panelPosition, devtoolsHeight, devtoolsWidth]) const { style: panelStyle = {}, ...otherPanelProps } = panelProps - const { - style: closeButtonStyle = {}, - onClick: onCloseClick, - ...otherCloseButtonProps - } = closeButtonProps - const { style: toggleButtonStyle = {}, onClick: onToggleClick, ...otherToggleButtonProps } = toggleButtonProps + // get computed style based on panel position + const style = getSidePanelStyle({ + position: panelPosition, + devtoolsTheme: theme, + isOpen: isResolvedOpen, + height: devtoolsHeight, + width: devtoolsWidth, + isResizing, + panelStyle, + }) + // Do not render on the server if (!isMounted()) return null @@ -247,80 +316,16 @@ export function ReactQueryDevtools({ ref={panelRef as any} context={context} styleNonce={styleNonce} + position={panelPosition} + onPositionChange={setPanelPosition} + showCloseButton + closeButtonProps={closeButtonProps} {...otherPanelProps} - style={{ - direction: 'ltr', - position: 'fixed', - bottom: '0', - right: '0', - zIndex: 99999, - width: '100%', - height: devtoolsHeight ?? 500, - maxHeight: '90%', - boxShadow: '0 0 20px rgba(0,0,0,.3)', - borderTop: `1px solid ${theme.gray}`, - transformOrigin: 'top', - // visibility will be toggled after transitions, but set initial state here - visibility: isOpen ? 'visible' : 'hidden', - ...panelStyle, - ...(isResizing - ? { - transition: `none`, - } - : { transition: `all .2s ease` }), - ...(isResolvedOpen - ? { - opacity: 1, - pointerEvents: 'all', - transform: `translateY(0) scale(1)`, - } - : { - opacity: 0, - pointerEvents: 'none', - transform: `translateY(15px) scale(1.02)`, - }), - }} + style={style} isOpen={isResolvedOpen} setIsOpen={setIsOpen} - handleDragStart={(e) => handleDragStart(panelRef.current, e)} + onDragStart={(e) => handleDragStart(panelRef.current, e)} /> - {isResolvedOpen ? ( - - ) : null} {!isResolvedOpen ? (