diff --git a/.changeset/close-drawer-on-outside-click.md b/.changeset/close-drawer-on-outside-click.md new file mode 100644 index 0000000000..020059f4e3 --- /dev/null +++ b/.changeset/close-drawer-on-outside-click.md @@ -0,0 +1,9 @@ +--- +'@hyperdx/app': patch +--- + +Detail drawers now close when you click outside of them. On Search and Sessions, +clicking outside the results table / session list dismisses the open drawer; +clicks inside the drawer, its nested popups/modals, or the results table keep it +open. This is on by default for row-table side panels (opt out with +`closeOnClickOutside={false}`). diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 42c2715d32..9995a976dd 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -202,6 +202,12 @@ type SearchConfigFromSchema = z.infer; const QUERY_KEY_PREFIX = 'search'; +// Clicks inside the results panel keep the row side panel open (so users can +// scroll the table or select a different row); clicks anywhere else on the page +// dismiss it. +const SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR = + '[data-testid="search-results-panel"]'; + // Helper function to get the default source id export function getDefaultSourceId( sources: { id: string; disabled?: boolean }[] | undefined, @@ -2297,6 +2303,7 @@ export function DBSearchPage() { focusDate={directTraceFocusDate} onClose={closeDirectTraceSidePanel} onSourceChange={onDirectTraceSourceChange} + keepOpenSelector={SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR} /> ) : ( - + {chartConfig && searchedConfig.source && dbSqlRowTableConfig && ( @@ -2622,6 +2634,9 @@ export function DBSearchPage() { config={dbSqlRowTableConfig} sourceId={searchedConfig.source} tableId={columnSizeTableId} + keepOpenSelector={ + SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR + } onSidebarOpen={onSidebarOpen} onExpandedRowsChange={onExpandedRowsChange} enabled={isReady} diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index f59d499575..a5ff7414ae 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -37,6 +37,7 @@ import { import SidePanelBreadcrumbs, { BreadcrumbItem, } from '@/components/SidePanelBreadcrumbs'; +import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside'; import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import { @@ -79,6 +80,8 @@ export default function SessionSidePanel({ onLanguageChange, onClose, zIndex = 100, + closeOnClickOutside = true, + keepOpenSelector, }: { traceSource: TTraceSource; sessionSource: TSessionSource; @@ -90,6 +93,8 @@ export default function SessionSidePanel({ onLanguageChange?: (lang: 'sql' | 'lucene') => void; onClose: () => void; zIndex?: number; + closeOnClickOutside?: boolean; + keepOpenSelector?: string; }) { // A single in-place event view (session → event), persisted to the URL so it // survives reload and shared links. Deeper navigation (View Trace, @@ -146,6 +151,14 @@ export default function SessionSidePanel({ useHotkeys(['esc'], handleClose, { enabled: !selectedEvent }); + // Match the Esc behavior: dismiss on outside click only at the session root, + // so deep in-panel navigation (event → trace → context) isn't skipped. + useCloseOnClickOutside({ + enabled: closeOnClickOutside && sessionId != null && !selectedEvent, + keepOpenSelector, + onClose: handleClose, + }); + const shareSession = useCallback(async () => { const ok = await copyTextToClipboard(window.location.href); notifications.show( diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 8fed41e8f5..4ce37009c2 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -215,6 +215,10 @@ function SessionCardList({ ); } +// Clicks inside the session list keep the session side panel open (so users can +// scroll or pick a different session); clicks anywhere else dismiss it. +const SESSION_LIST_KEEP_OPEN_SELECTOR = '[data-testid="session-card-list"]'; + // TODO: This is a hack to set the default time range const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date]; const selectedSessionQueryStateMap = { @@ -382,6 +386,7 @@ function SessionsPage() { onClose={() => { setSelectedSession(undefined); }} + keepOpenSelector={SESSION_LIST_KEEP_OPEN_SELECTOR} whereLanguage={whereLanguage || undefined} where={where || undefined} onLanguageChange={lang => @@ -474,7 +479,10 @@ function SessionsPage() { ) : ( -
+
{ setSelectedSession(session); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 2144d6cd95..89a2fcc580 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -35,6 +35,7 @@ import { } from '@mantine/core'; import { IconCopy, IconKeyboard, IconShare, IconX } from '@tabler/icons-react'; +import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside'; import useResizable from '@/hooks/useResizable'; import { WithClause } from '@/hooks/useRowWhere'; import useSidePanelStack, { @@ -201,6 +202,10 @@ type DBRowSidePanelProps = { rowId: string | undefined; aliasWith?: WithClause[]; onClose: () => void; + // Clicking outside the drawer (and outside `keepOpenSelector`) closes it. + // Enabled by default; pass `false` to opt out. + closeOnClickOutside?: boolean; + keepOpenSelector?: string; }; type DBRowSidePanelInnerProps = DBRowSidePanelProps & { @@ -1067,6 +1072,8 @@ export default function DBRowSidePanelErrorBoundary({ rowId, aliasWith, source, + closeOnClickOutside = true, + keepOpenSelector, }: DBRowSidePanelProps) { const contextZIndex = useZIndex(); const drawerZIndex = contextZIndex + 10; @@ -1093,6 +1100,20 @@ export default function DBRowSidePanelErrorBoundary({ onClose(); }, [sidePanelStack, onClose, clearTraceWaterfallSearchState]); + useCloseOnClickOutside({ + // Only close on outside click at the root level. When the user has + // navigated deeper (e.g. log row -> trace), Esc pops one level at a time + // via handlePanelBack; an outside click should not skip those levels and + // close the drawer entirely. + enabled: + closeOnClickOutside && + rowId != null && + sidePanelStack.sourceStack.length === 0 && + sidePanelStack.navStack.length === 0, + keepOpenSelector, + onClose: _onClose, + }); + return ( void; + // Clicking outside the row side panel (and outside `keepOpenSelector`) closes + // it. Enabled by default; pass `false` to opt out. + closeOnClickOutside?: boolean; + keepOpenSelector?: string; } +// Clicking the results table (selecting/switching rows, scrolling) keeps the +// row side panel open by default; callers can widen this via `keepOpenSelector`. +const DEFAULT_KEEP_OPEN_SELECTOR = '[data-testid="search-results-table"]'; + export default function DBSqlRowTableWithSideBar({ sourceId, config, @@ -67,6 +75,8 @@ export default function DBSqlRowTableWithSideBar({ tableId, errorVariant, onResolvedColumnsChange, + closeOnClickOutside = true, + keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR, }: Props) { const { data: sourceData } = useSource({ id: sourceId }); const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded); @@ -111,6 +121,8 @@ export default function DBSqlRowTableWithSideBar({ rowId={rowId ?? undefined} aliasWith={aliasWith} onClose={onCloseSidebar} + closeOnClickOutside={closeOnClickOutside} + keepOpenSelector={keepOpenSelector} /> )} void; onSourceChange: (sourceId: string | null) => void; + closeOnClickOutside?: boolean; + keepOpenSelector?: string; } export default function DirectTraceSidePanel({ @@ -27,7 +30,15 @@ export default function DirectTraceSidePanel({ focusDate, onClose, onSourceChange, + closeOnClickOutside = true, + keepOpenSelector, }: DirectTraceSidePanelProps) { + useCloseOnClickOutside({ + enabled: closeOnClickOutside && opened, + keepOpenSelector, + onClose, + }); + const { control, setValue } = useForm<{ source: string | null }>({ defaultValues: { source: traceSourceId ?? null, diff --git a/packages/app/src/hooks/__tests__/useCloseOnClickOutside.test.tsx b/packages/app/src/hooks/__tests__/useCloseOnClickOutside.test.tsx new file mode 100644 index 0000000000..62b5bc77f3 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useCloseOnClickOutside.test.tsx @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useCloseOnClickOutside } from '@/hooks/useCloseOnClickOutside'; + +function mouseDownOn(el: Element) { + act(() => { + el.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true }), + ); + }); +} + +describe('useCloseOnClickOutside', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('closes when clicking a plain element outside the safe zones', () => { + const outside = document.createElement('div'); + document.body.appendChild(outside); + + const onClose = jest.fn(); + renderHook(() => useCloseOnClickOutside({ enabled: true, onClose })); + + mouseDownOn(outside); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close when clicking inside the keepOpen selector', () => { + const table = document.createElement('div'); + table.setAttribute('data-testid', 'results'); + const cell = document.createElement('span'); + table.appendChild(cell); + document.body.appendChild(table); + + const onClose = jest.fn(); + renderHook(() => + useCloseOnClickOutside({ + enabled: true, + keepOpenSelector: '[data-testid="results"]', + onClose, + }), + ); + + mouseDownOn(cell); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does not close when clicking inside a floating layer (dialog/dropdown)', () => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const dialogChild = document.createElement('button'); + dialog.appendChild(dialogChild); + + const dropdown = document.createElement('div'); + dropdown.className = 'mantine-Select-dropdown'; + const option = document.createElement('div'); + dropdown.appendChild(option); + + document.body.append(dialog, dropdown); + + const onClose = jest.fn(); + renderHook(() => useCloseOnClickOutside({ enabled: true, onClose })); + + mouseDownOn(dialogChild); + mouseDownOn(option); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('does nothing while disabled and detaches its listener on cleanup', () => { + const outside = document.createElement('div'); + document.body.appendChild(outside); + + const onClose = jest.fn(); + const { rerender, unmount } = renderHook( + ({ enabled }) => useCloseOnClickOutside({ enabled, onClose }), + { initialProps: { enabled: false } }, + ); + + mouseDownOn(outside); + expect(onClose).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + mouseDownOn(outside); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + mouseDownOn(outside); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/useCloseOnClickOutside.ts b/packages/app/src/hooks/useCloseOnClickOutside.ts new file mode 100644 index 0000000000..96c6abb549 --- /dev/null +++ b/packages/app/src/hooks/useCloseOnClickOutside.ts @@ -0,0 +1,71 @@ +import { useEffect } from 'react'; + +import { useStableCallback } from './useStableCallback'; + +// Floating UI layers (drawers, modals, popovers, dropdowns, menus, tooltips, +// overlays) are frequently portaled to `document.body`, so DOM containment +// checks against the drawer/table elements alone can't tell whether a click +// landed inside a nested popup. Matching against the roles/classes Mantine +// applies to these layers lets us treat clicks inside any of them (including +// a Select dropdown or child modal opened from within the drawer) as "inside". +const FLOATING_LAYER_SELECTOR = [ + '[role="dialog"]', + '[role="tooltip"]', + '[role="listbox"]', + '[role="menu"]', + // role="grid" is used by both Mantine date-picker calendars and ordinary + // data tables; scope to Mantine calendars so we don't swallow clicks on + // unrelated tables elsewhere on the page. + '[class*="mantine-"][class*="calendar"] [role="grid"]', + // Catch every Mantine dropdown variant (Popover, Combobox, Select, Menu, …) + '[class*="mantine-"][class*="dropdown"]', + // Overlays belong to modals layered on top of the (overlay-less) drawer. + '[class*="mantine-Overlay"]', +].join(','); + +/** + * Closes an open, overlay-less drawer when the user clicks outside of it. + * + * The drawer content itself (any `role="dialog"` element) and any floating + * layer opened from within it are ignored automatically. Pass + * `keepOpenSelector` to designate additional "safe" regions — e.g. the results + * table — where clicks should keep the drawer open instead of closing it. + */ +export function useCloseOnClickOutside({ + enabled, + keepOpenSelector, + onClose, +}: { + enabled: boolean; + keepOpenSelector?: string; + onClose: () => void; +}) { + const stableOnClose = useStableCallback(onClose); + + useEffect(() => { + if (!enabled) { + return undefined; + } + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + if (target.closest(FLOATING_LAYER_SELECTOR)) { + return; + } + if (keepOpenSelector && target.closest(keepOpenSelector)) { + return; + } + stableOnClose(); + }; + + // Capture phase so we still see the click even if a child stops + // propagation during the bubble phase. + document.addEventListener('mousedown', handleMouseDown, true); + return () => { + document.removeEventListener('mousedown', handleMouseDown, true); + }; + }, [enabled, keepOpenSelector, stableOnClose]); +}