From 642609d1c436cd2be9c1dc93e0f59243a58f1534 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Sun, 19 Jul 2026 12:57:33 +0100 Subject: [PATCH 1/4] feat(app): close search & session drawers on outside click Co-authored-by: Cursor --- .changeset/close-drawer-on-outside-click.md | 7 ++ packages/app/src/DBSearchPage.tsx | 19 +++- packages/app/src/SessionSidePanel.tsx | 13 +++ packages/app/src/SessionsPage.tsx | 11 ++- .../app/src/components/DBRowSidePanel.tsx | 13 +++ .../components/DBSqlRowTableWithSidebar.tsx | 8 ++ .../Search/DirectTraceSidePanel.tsx | 11 +++ .../__tests__/useCloseOnClickOutside.test.tsx | 91 +++++++++++++++++++ .../app/src/hooks/useCloseOnClickOutside.ts | 68 ++++++++++++++ 9 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 .changeset/close-drawer-on-outside-click.md create mode 100644 packages/app/src/hooks/__tests__/useCloseOnClickOutside.test.tsx create mode 100644 packages/app/src/hooks/useCloseOnClickOutside.ts diff --git a/.changeset/close-drawer-on-outside-click.md b/.changeset/close-drawer-on-outside-click.md new file mode 100644 index 0000000000..ba314f9d00 --- /dev/null +++ b/.changeset/close-drawer-on-outside-click.md @@ -0,0 +1,7 @@ +--- +'@hyperdx/app': patch +--- + +Search and Sessions: clicking outside the results table (or session list) now +closes the open detail drawer. Clicks inside the drawer, its nested +popups/modals, or the results table still keep it open. diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 42c2715d32..466fbf782d 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,8 @@ export function DBSearchPage() { focusDate={directTraceFocusDate} onClose={closeDirectTraceSidePanel} onSourceChange={onDirectTraceSourceChange} + closeOnClickOutside + keepOpenSelector={SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR} /> ) : ( - + {chartConfig && searchedConfig.source && dbSqlRowTableConfig && ( @@ -2622,6 +2635,10 @@ export function DBSearchPage() { config={dbSqlRowTableConfig} sourceId={searchedConfig.source} tableId={columnSizeTableId} + closeOnClickOutside + 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..e13798e460 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 = false, + 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..1dc3b6c400 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,8 @@ function SessionsPage() { onClose={() => { setSelectedSession(undefined); }} + closeOnClickOutside + keepOpenSelector={SESSION_LIST_KEEP_OPEN_SELECTOR} whereLanguage={whereLanguage || undefined} where={where || undefined} onLanguageChange={lang => @@ -474,7 +480,10 @@ function SessionsPage() { ) : ( -
+
{ setSelectedSession(session); diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 2144d6cd95..71e8d919cf 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; + // When enabled, clicking outside the drawer (and outside `keepOpenSelector`) + // closes it. Off by default so other consumers keep their existing behavior. + closeOnClickOutside?: boolean; + keepOpenSelector?: string; }; type DBRowSidePanelInnerProps = DBRowSidePanelProps & { @@ -1067,6 +1072,8 @@ export default function DBRowSidePanelErrorBoundary({ rowId, aliasWith, source, + closeOnClickOutside = false, + keepOpenSelector, }: DBRowSidePanelProps) { const contextZIndex = useZIndex(); const drawerZIndex = contextZIndex + 10; @@ -1093,6 +1100,12 @@ export default function DBRowSidePanelErrorBoundary({ onClose(); }, [sidePanelStack, onClose, clearTraceWaterfallSearchState]); + useCloseOnClickOutside({ + enabled: closeOnClickOutside && rowId != null, + keepOpenSelector, + onClose: _onClose, + }); + return ( void; + // When enabled, clicking outside the row side panel (and outside + // `keepOpenSelector`) closes it. + closeOnClickOutside?: boolean; + keepOpenSelector?: string; } export default function DBSqlRowTableWithSideBar({ @@ -67,6 +71,8 @@ export default function DBSqlRowTableWithSideBar({ tableId, errorVariant, onResolvedColumnsChange, + closeOnClickOutside = false, + keepOpenSelector, }: Props) { const { data: sourceData } = useSource({ id: sourceId }); const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded); @@ -111,6 +117,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 = false, + 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..f7060f4faa --- /dev/null +++ b/packages/app/src/hooks/useCloseOnClickOutside.ts @@ -0,0 +1,68 @@ +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"]', + // 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]); +} From fbdc0960a49e312eda8dcf9d43d8e946250d156a Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Sun, 19 Jul 2026 13:27:19 +0100 Subject: [PATCH 2/4] refactor(app): default closeOnClickOutside to true for row-table drawers Co-authored-by: Cursor --- .changeset/close-drawer-on-outside-click.md | 8 +++++--- packages/app/src/DBSearchPage.tsx | 2 -- packages/app/src/SessionSidePanel.tsx | 2 +- packages/app/src/SessionsPage.tsx | 1 - packages/app/src/components/DBRowSidePanel.tsx | 6 +++--- .../app/src/components/DBSqlRowTableWithSidebar.tsx | 12 ++++++++---- .../src/components/Search/DirectTraceSidePanel.tsx | 2 +- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.changeset/close-drawer-on-outside-click.md b/.changeset/close-drawer-on-outside-click.md index ba314f9d00..020059f4e3 100644 --- a/.changeset/close-drawer-on-outside-click.md +++ b/.changeset/close-drawer-on-outside-click.md @@ -2,6 +2,8 @@ '@hyperdx/app': patch --- -Search and Sessions: clicking outside the results table (or session list) now -closes the open detail drawer. Clicks inside the drawer, its nested -popups/modals, or the results table still keep it open. +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 466fbf782d..9995a976dd 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -2303,7 +2303,6 @@ export function DBSearchPage() { focusDate={directTraceFocusDate} onClose={closeDirectTraceSidePanel} onSourceChange={onDirectTraceSourceChange} - closeOnClickOutside keepOpenSelector={SEARCH_RESULTS_PANEL_KEEP_OPEN_SELECTOR} /> { setSelectedSession(undefined); }} - closeOnClickOutside keepOpenSelector={SESSION_LIST_KEEP_OPEN_SELECTOR} whereLanguage={whereLanguage || undefined} where={where || undefined} diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 71e8d919cf..08c7c18a84 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -202,8 +202,8 @@ type DBRowSidePanelProps = { rowId: string | undefined; aliasWith?: WithClause[]; onClose: () => void; - // When enabled, clicking outside the drawer (and outside `keepOpenSelector`) - // closes it. Off by default so other consumers keep their existing behavior. + // Clicking outside the drawer (and outside `keepOpenSelector`) closes it. + // Enabled by default; pass `false` to opt out. closeOnClickOutside?: boolean; keepOpenSelector?: string; }; @@ -1072,7 +1072,7 @@ export default function DBRowSidePanelErrorBoundary({ rowId, aliasWith, source, - closeOnClickOutside = false, + closeOnClickOutside = true, keepOpenSelector, }: DBRowSidePanelProps) { const contextZIndex = useZIndex(); diff --git a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx index c58b362d5d..aa8832fe2a 100644 --- a/packages/app/src/components/DBSqlRowTableWithSidebar.tsx +++ b/packages/app/src/components/DBSqlRowTableWithSidebar.tsx @@ -46,12 +46,16 @@ interface Props { tableId?: string; errorVariant?: ChartErrorStateVariant; onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void; - // When enabled, clicking outside the row side panel (and outside - // `keepOpenSelector`) closes it. + // 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, @@ -71,8 +75,8 @@ export default function DBSqlRowTableWithSideBar({ tableId, errorVariant, onResolvedColumnsChange, - closeOnClickOutside = false, - keepOpenSelector, + closeOnClickOutside = true, + keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR, }: Props) { const { data: sourceData } = useSource({ id: sourceId }); const [rowId, setRowId] = useQueryState('rowWhere', parseAsStringEncoded); diff --git a/packages/app/src/components/Search/DirectTraceSidePanel.tsx b/packages/app/src/components/Search/DirectTraceSidePanel.tsx index c9c305f66e..9c27dccd23 100644 --- a/packages/app/src/components/Search/DirectTraceSidePanel.tsx +++ b/packages/app/src/components/Search/DirectTraceSidePanel.tsx @@ -30,7 +30,7 @@ export default function DirectTraceSidePanel({ focusDate, onClose, onSourceChange, - closeOnClickOutside = false, + closeOnClickOutside = true, keepOpenSelector, }: DirectTraceSidePanelProps) { useCloseOnClickOutside({ From 0bb09fae79c4e8f4e9da6b97c16a9fa3122479f3 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 20 Jul 2026 11:33:57 +0100 Subject: [PATCH 3/4] fix(app): scope role="grid" match to Mantine calendars in useCloseOnClickOutside Co-authored-by: Cursor --- packages/app/src/hooks/useCloseOnClickOutside.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/hooks/useCloseOnClickOutside.ts b/packages/app/src/hooks/useCloseOnClickOutside.ts index f7060f4faa..96c6abb549 100644 --- a/packages/app/src/hooks/useCloseOnClickOutside.ts +++ b/packages/app/src/hooks/useCloseOnClickOutside.ts @@ -13,7 +13,10 @@ const FLOATING_LAYER_SELECTOR = [ '[role="tooltip"]', '[role="listbox"]', '[role="menu"]', - '[role="grid"]', + // 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. From 0031d7e1581f40e980396ca19e9dfcf5acef4dc5 Mon Sep 17 00:00:00 2001 From: Elizabet Oliveira Date: Mon, 20 Jul 2026 11:40:35 +0100 Subject: [PATCH 4/4] fix: only close row side panel on outside click at root level Co-authored-by: Cursor --- packages/app/src/components/DBRowSidePanel.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 08c7c18a84..89a2fcc580 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -1101,7 +1101,15 @@ export default function DBRowSidePanelErrorBoundary({ }, [sidePanelStack, onClose, clearTraceWaterfallSearchState]); useCloseOnClickOutside({ - enabled: closeOnClickOutside && rowId != null, + // 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, });