From 1cc12f412f5c3933c9d835618a3bc0adb8c416fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:23:14 +0000 Subject: [PATCH] test: add unit tests for useClickOutside composable Cover the key behaviours of useClickOutside: - Lifecycle: click and pointerdown listeners registered on mount, deregistered on unmount (it.each for both events) - Click handler: callback called for outside click; not called when click target equals or is inside the component element; not called when component.value is null; no throw when callback is non-function - Ignore option (CSS selector): callback suppressed after a pointerdown on a selector-matched element; callback fires when pointerdown is on a non-ignored element outside the component - Pointerdown inside component: drag-inside-then-click-outside does not fire callback (setShouldListen guard) - Keyboard click (detail=0): callback suppressed when the click event target matches an ignore selector 13 tests total; all pass with 0 ESLint warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../__tests__/useClickOutside.test.ts | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 shell/composables/__tests__/useClickOutside.test.ts diff --git a/shell/composables/__tests__/useClickOutside.test.ts b/shell/composables/__tests__/useClickOutside.test.ts new file mode 100644 index 00000000000..7f4f41a6bd0 --- /dev/null +++ b/shell/composables/__tests__/useClickOutside.test.ts @@ -0,0 +1,232 @@ +import { defineComponent, ref } from 'vue'; +import { mount } from '@vue/test-utils'; +import type { VueWrapper } from '@vue/test-utils'; +import { useClickOutside } from '@shell/composables/useClickOutside'; + +describe('useClickOutside', () => { + let capturedHandlers: Record void>; + let addEventSpy: jest.SpyInstance; + let removeEventSpy: jest.SpyInstance; + + beforeEach(() => { + capturedHandlers = {}; + addEventSpy = jest.spyOn(window, 'addEventListener').mockImplementation((event: string, handler: any) => { + capturedHandlers[event] = handler; + }); + removeEventSpy = jest.spyOn(window, 'removeEventListener').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function mountWithHook( + componentEl: HTMLElement, + callback: any, + options: { ignore?: string[] } = {} + ): VueWrapper { + const componentRef = ref(componentEl); + const TestComponent = defineComponent({ + setup() { + useClickOutside(componentRef, callback, options); + + return {}; + }, + template: '
', + }); + + return mount(TestComponent); + } + + function makeEvent(overrides: Partial<{ target: Element; composedPath: () => Element[]; detail: number }> = {}): any { + return { + target: document.createElement('div'), + composedPath: () => [], + detail: 1, + ...overrides, + }; + } + + describe('lifecycle', () => { + it.each([ + { desc: 'click', event: 'click' }, + { desc: 'pointerdown', event: 'pointerdown' }, + ])('registers the $desc event listener on mount', ({ event }) => { + mountWithHook(document.createElement('div'), jest.fn()); + + expect(addEventSpy).toHaveBeenCalledWith(event, expect.any(Function)); + }); + + it.each([ + { desc: 'click', event: 'click' }, + { desc: 'pointerdown', event: 'pointerdown' }, + ])('removes the $desc event listener on unmount', ({ event }) => { + const wrapper = mountWithHook(document.createElement('div'), jest.fn()); + + wrapper.unmount(); + + expect(removeEventSpy).toHaveBeenCalledWith(event, expect.any(Function)); + }); + }); + + describe('click listener', () => { + it('calls callback when click target is outside the component element', () => { + const target = document.createElement('div'); + const outsideEl = document.createElement('span'); + const callback = jest.fn(); + + mountWithHook(target, callback); + + capturedHandlers['click'](makeEvent({ target: outsideEl, composedPath: () => [outsideEl] })); + + expect(callback).toHaveBeenCalledWith(); + }); + + it('does not call callback when click target is the component element', () => { + const target = document.createElement('div'); + const callback = jest.fn(); + + mountWithHook(target, callback); + + capturedHandlers['click'](makeEvent({ target, composedPath: () => [target] })); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not call callback when the composedPath of the click includes the component element', () => { + const target = document.createElement('div'); + const child = document.createElement('span'); + const callback = jest.fn(); + + mountWithHook(target, callback); + + capturedHandlers['click'](makeEvent({ target: child, composedPath: () => [child, target] })); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not call callback when component.value is null', () => { + const callback = jest.fn(); + const componentRef = ref(null); + const TestComponent = defineComponent({ + setup() { + useClickOutside(componentRef, callback); + + return {}; + }, + template: '
', + }); + + mount(TestComponent); + + capturedHandlers['click'](makeEvent()); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('does not throw when callback is not a function', () => { + const target = document.createElement('div'); + const outsideEl = document.createElement('span'); + + mountWithHook(target, 'not-a-function'); + + expect(() => { + capturedHandlers['click'](makeEvent({ target: outsideEl, composedPath: () => [outsideEl] })); + }).not.toThrow(); + }); + }); + + describe('ignore option', () => { + it('suppresses callback when a prior pointerdown targeted a css-selector-matched ignore element', () => { + const target = document.createElement('div'); + const ignoredEl = document.createElement('button'); + + ignoredEl.className = 'dropdown-menu'; + document.body.appendChild(ignoredEl); + + const callback = jest.fn(); + + mountWithHook(target, callback, { ignore: ['.dropdown-menu'] }); + + capturedHandlers['pointerdown'](makeEvent({ + target: ignoredEl, + composedPath: () => [ignoredEl], + })); + + capturedHandlers['click'](makeEvent({ + target: document.createElement('span'), + composedPath: () => [document.createElement('span')], + })); + + expect(callback).not.toHaveBeenCalled(); + + document.body.removeChild(ignoredEl); + }); + + it('calls callback when pointerdown targets a non-ignored element outside the component', () => { + const target = document.createElement('div'); + const outsideEl = document.createElement('span'); + const callback = jest.fn(); + + mountWithHook(target, callback, { ignore: ['.dropdown-menu'] }); + + capturedHandlers['pointerdown'](makeEvent({ + target: outsideEl, + composedPath: () => [outsideEl], + })); + + capturedHandlers['click'](makeEvent({ + target: outsideEl, + composedPath: () => [outsideEl], + })); + + expect(callback).toHaveBeenCalledWith(); + }); + }); + + describe('pointerdown inside component', () => { + it('does not call callback when pointerdown was inside the component and click was outside', () => { + const target = document.createElement('div'); + const outsideEl = document.createElement('span'); + const callback = jest.fn(); + + mountWithHook(target, callback); + + capturedHandlers['pointerdown'](makeEvent({ + target, + composedPath: () => [target], + })); + + capturedHandlers['click'](makeEvent({ + target: outsideEl, + composedPath: () => [outsideEl], + })); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard click (detail=0)', () => { + it('does not call callback when detail is 0 and click event target matches an ignore selector', () => { + const target = document.createElement('div'); + const ignoredEl = document.createElement('button'); + + ignoredEl.className = 'skip-kb'; + document.body.appendChild(ignoredEl); + + const callback = jest.fn(); + + mountWithHook(target, callback, { ignore: ['.skip-kb'] }); + + capturedHandlers['click'](makeEvent({ + target: ignoredEl, + composedPath: () => [ignoredEl], + detail: 0, + })); + + expect(callback).not.toHaveBeenCalled(); + + document.body.removeChild(ignoredEl); + }); + }); +});