diff --git a/webui/src/Layout/Sidebar.tsx b/webui/src/Layout/Sidebar.tsx index 11c0eb04a0..ba954b09b0 100644 --- a/webui/src/Layout/Sidebar.tsx +++ b/webui/src/Layout/Sidebar.tsx @@ -648,9 +648,9 @@ function SidebarRoot({ if (mobileMode) setVisibleMobile(false) }, [mobileMode]) - // handle clicks in the sidebar for mobile mode and "unfolding" mode - const handleOnClick = useCallback( - (event: MouseEvent) => { + // handle completed pointer interactions in the sidebar for mobile mode and "unfolding" mode + const handlePointerUp = useCallback( + (event: PointerEvent) => { const target = event.target // note: middle-click currently opens the nav-link target in a new tab, so it makes sense to close the sidebar in that case. // Only context-menu should leave the sidebar alone, since it is acting on the current sidebar, hence "event.button === 2". @@ -679,6 +679,15 @@ function SidebarRoot({ [setNarrow, mobileMode, unfoldable] ) + // Touch pointers do not hover, so entering a temporarily narrowed folding sidebar must explicitly expand it. + // The subsequent pointerup will schedule the fold again if a navigation item is selected. + const handlePointerEnter = useCallback( + (event: React.PointerEvent) => { + if (narrow && event.pointerType === 'touch') setNarrow(false) + }, + [narrow, setNarrow] + ) + // if in "temporary narrow-mode" return to folding mode after the mouse leaves the sidebar // note that in "permanent" narrow-mode, setNarrow is passed as a no-op, so this callback is active only when not in narrow-mode const handleMouseLeave = useCallback(() => { @@ -695,20 +704,20 @@ function SidebarRoot({ ) useEffect(() => { - window.addEventListener('mouseup', handleKeyOrClickOutside) + window.addEventListener('pointerup', handleKeyOrClickOutside) window.addEventListener('keyup', handleKeyOrClickOutside) const sideBarElement = sidebarRef.current - sideBarElement?.addEventListener('mouseup', handleOnClick) + sideBarElement?.addEventListener('pointerup', handlePointerUp) return () => { - window.removeEventListener('mouseup', handleKeyOrClickOutside) + window.removeEventListener('pointerup', handleKeyOrClickOutside) window.removeEventListener('keyup', handleKeyOrClickOutside) - sideBarElement?.removeEventListener('mouseup', handleOnClick) + sideBarElement?.removeEventListener('pointerup', handlePointerUp) } - }, [sidebarRef, handleOnClick, handleKeyOrClickOutside]) + }, [sidebarRef, handlePointerUp, handleKeyOrClickOutside]) return ( <> @@ -726,6 +735,7 @@ function SidebarRoot({ })} ref={sidebarRef} onMouseLeave={handleMouseLeave} + onPointerEnter={handlePointerEnter} onContextMenu={onContextMenu} > {children} diff --git a/webui/src/Layout/__tests__/Sidebar.test.tsx b/webui/src/Layout/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000000..fd6a2abf45 --- /dev/null +++ b/webui/src/Layout/__tests__/Sidebar.test.tsx @@ -0,0 +1,209 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import type { MouseEvent, ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MySidebar, SidebarStateProvider, useSidebarState } from '../Sidebar' + +const { layoutMode, routeSelection } = vi.hoisted(() => ({ + layoutMode: { mobile: false }, + routeSelection: vi.fn(), +})) + +vi.mock('@tanstack/react-router', async () => { + const { forwardRef } = await import('react') + + return { + Link: forwardRef< + HTMLAnchorElement, + { + children: ReactNode + className?: string + onClick?: (event: MouseEvent) => void + target?: string + title?: string + to: string + } + >(({ children, onClick, to, ...props }, ref) => ( + { + onClick?.(event) + if (!event.defaultPrevented) routeSelection(to) + }} + > + {children} + + )), + useMatchRoute: () => () => false, + } +}) + +vi.mock('~/Hooks/useLayoutMode', () => ({ + useMobileMode: () => layoutMode.mobile, +})) + +vi.mock('~/Stores/RootAppStore.js', async () => { + const { createContext } = await import('react') + + return { + RootAppStoreContext: createContext({ + connections: { rootCollections: () => [] }, + modules: { getModuleFriendlyName: () => '' }, + whatsNewModal: { current: null }, + wizardOpen: { set: vi.fn() }, + }), + } +}) + +vi.mock('~/Stores/Util.js', () => ({ + useSortedConnectionsThatHaveVariables: () => [], +})) + +vi.mock('~/Surfaces/TabNotifyIcon.js', () => ({ + ConnectionsTabNotifyIcon: () => null, + SurfacesTabNotifyIcon: () => null, +})) + +vi.mock('~/Components/ContextMenu', () => ({ + ContextMenu: () => null, +})) + +vi.mock('~/Components/useContextMenuProps', () => ({ + MenuSeparator: {}, + useContextMenuState: () => ({ onContextMenu: vi.fn() }), +})) + +vi.mock('~/Components/Tooltip.js', () => ({ + Tooltip: { + Root: ({ children }: { children: ReactNode }) => children, + Trigger: ({ render }: { render: ReactNode }) => render, + Popup: ({ children }: { children: ReactNode }) => children, + }, +})) + +vi.mock('../SidebarHeader', () => ({ + SidebarHeader: () => null, + SidebarFooter: () => ( +
+ +
+ ), +})) + +function ShowSidebarButton() { + const { handleShowSidebar } = useSidebarState() + return ( + + ) +} + +function renderSidebar({ mobile = false }: { mobile?: boolean } = {}) { + layoutMode.mobile = mobile + window.localStorage.setItem('sidebar_foldable', 'true') + window.localStorage.setItem('sidebar_narrow_mode', 'false') + + const result = render( + + + + + ) + + return { + ...result, + sidebar: result.container.querySelector('.sidebar') as HTMLElement, + } +} + +function finishDeferredFold() { + act(() => vi.runAllTimers()) +} + +describe('Sidebar folding pointer interactions', () => { + beforeEach(() => { + vi.useFakeTimers() + window.localStorage.clear() + routeSelection.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('folds after a primary touch selection without interfering with navigation', () => { + const { sidebar } = renderSidebar() + const link = screen.getByRole('link', { name: 'Connections' }) + + fireEvent.pointerUp(link, { button: 0, pointerType: 'touch' }) + fireEvent.click(link) + + expect(routeSelection).toHaveBeenCalledWith('/connections') + expect(sidebar).not.toHaveClass('sidebar-narrow') + + finishDeferredFold() + expect(sidebar).toHaveClass('sidebar-narrow') + }) + + it('expands a temporarily narrowed sidebar for a later touch and folds after that selection', () => { + const { sidebar } = renderSidebar() + const firstLink = screen.getByRole('link', { name: 'Connections' }) + const secondLink = screen.getByRole('link', { name: 'Image Library' }) + + fireEvent.pointerUp(firstLink, { button: 0, pointerType: 'touch' }) + finishDeferredFold() + expect(sidebar).toHaveClass('sidebar-narrow') + + fireEvent.pointerEnter(sidebar, { pointerType: 'touch' }) + expect(sidebar).not.toHaveClass('sidebar-narrow') + + fireEvent.pointerUp(secondLink, { button: 0, pointerType: 'touch' }) + expect(sidebar).not.toHaveClass('sidebar-narrow') + finishDeferredFold() + expect(sidebar).toHaveClass('sidebar-narrow') + }) + + it.each(['mouse', 'pen'])('folds after a primary %s pointer selection', (pointerType) => { + const { sidebar } = renderSidebar() + const link = screen.getByRole('link', { name: 'Connections' }) + + fireEvent.pointerUp(link, { button: 0, pointerType }) + finishDeferredFold() + + expect(sidebar).toHaveClass('sidebar-narrow') + }) + + it('does not fold for secondary buttons, group toggles, footer controls, or blank areas', () => { + const { sidebar } = renderSidebar() + const link = screen.getByRole('link', { name: 'Connections' }) + const groupToggle = screen.getAllByText('Surfaces')[0].closest('.nav-group-toggle') as HTMLElement + + fireEvent.pointerUp(link, { button: 2, pointerType: 'mouse' }) + fireEvent.pointerUp(groupToggle, { button: 0, pointerType: 'touch' }) + fireEvent.pointerUp(screen.getByRole('button', { name: 'Footer control' }), { + button: 0, + pointerType: 'touch', + }) + fireEvent.pointerUp(sidebar, { button: 0, pointerType: 'touch' }) + finishDeferredFold() + + expect(sidebar).not.toHaveClass('sidebar-narrow') + }) + + it('dismisses the mobile sidebar after selecting a link without applying desktop narrow state', () => { + const { sidebar } = renderSidebar({ mobile: true }) + fireEvent.click(screen.getByRole('button', { name: 'Show sidebar' })) + expect(sidebar).toHaveClass('show') + + const link = screen.getByRole('link', { name: 'Connections' }) + fireEvent.pointerUp(link, { button: 0, pointerType: 'touch' }) + fireEvent.click(link) + + expect(sidebar).not.toHaveClass('show') + finishDeferredFold() + expect(sidebar).not.toHaveClass('sidebar-narrow') + expect(routeSelection).toHaveBeenCalledWith('/connections') + }) +})