diff --git a/change/@fluentui-react-utilities-3d0f8452-1cf1-433e-9c40-cfdaae09343b.json b/change/@fluentui-react-utilities-3d0f8452-1cf1-433e-9c40-cfdaae09343b.json new file mode 100644 index 0000000000000..ec1c9a97992c9 --- /dev/null +++ b/change/@fluentui-react-utilities-3d0f8452-1cf1-433e-9c40-cfdaae09343b.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: `useOnScrollOutside` should invoke callback on dragging scrollbar", + "packageName": "@fluentui/react-utilities", + "email": "yuanboxue@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.test.ts b/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.test.ts index 669c369ba831b..0fe9d82959ace 100644 --- a/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.test.ts +++ b/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.test.ts @@ -2,9 +2,9 @@ import { renderHook } from '@testing-library/react-hooks'; import { useOnScrollOutside } from './useOnScrollOutside'; describe('useOnScrollOutside', () => { - const supportedEvents = ['wheel', 'touchmove']; + const supportedEvents = [{ event: 'wheel' }, { event: 'touchmove' }, { event: 'scroll', capture: true }]; - it.each(supportedEvents)('should add %s listener', event => { + it.each(supportedEvents)('should add %s listener', ({ event, capture }) => { // Arrange const element = { addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as Document; @@ -12,11 +12,13 @@ describe('useOnScrollOutside', () => { renderHook(() => useOnScrollOutside({ element, callback: jest.fn(), refs: [] })); // Assert - expect(element.addEventListener).toHaveBeenCalledTimes(2); - expect(element.addEventListener).toHaveBeenCalledWith(event, expect.anything()); + expect(element.addEventListener).toHaveBeenCalledTimes(supportedEvents.length); + expect(element.addEventListener).toHaveBeenCalledWith( + ...(capture ? [event, expect.anything(), true] : [event, expect.anything()]), + ); }); - it.each(supportedEvents)('should cleanup %s listener', event => { + it.each(supportedEvents)('should cleanup %s listener', ({ event, capture }) => { // Arrange const element = { addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as Document; @@ -25,8 +27,10 @@ describe('useOnScrollOutside', () => { unmount(); // Assert - expect(element.removeEventListener).toHaveBeenCalledTimes(2); - expect(element.removeEventListener).toHaveBeenCalledWith(event, expect.anything()); + expect(element.removeEventListener).toHaveBeenCalledTimes(supportedEvents.length); + expect(element.removeEventListener).toHaveBeenCalledWith( + ...(capture ? [event, expect.anything(), true] : [event, expect.anything()]), + ); }); it('should not add or remove event listeners when disabled', () => { diff --git a/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.ts b/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.ts index 86043fe1c6032..f6d6ce6246c99 100644 --- a/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.ts +++ b/packages/react-components/react-utilities/src/hooks/useOnScrollOutside.ts @@ -9,7 +9,7 @@ import type { UseOnClickOrScrollOutsideOptions } from './useOnClickOutside'; export const useOnScrollOutside = (options: UseOnClickOrScrollOutsideOptions) => { const { refs, callback, element, disabled, contains: containsProp } = options; - const listener = useEventCallback((ev: MouseEvent | TouchEvent) => { + const listener = useEventCallback((ev: Event) => { const contains: UseOnClickOrScrollOutsideOptions['contains'] = containsProp || ((parent, child) => !!parent?.contains(child)); @@ -17,7 +17,7 @@ export const useOnScrollOutside = (options: UseOnClickOrScrollOutsideOptions) => const isOutside = refs.every(ref => !contains(ref.current || null, target)); if (isOutside && !disabled) { - callback(ev); + callback(ev as MouseEvent | TouchEvent); } }); @@ -28,10 +28,13 @@ export const useOnScrollOutside = (options: UseOnClickOrScrollOutsideOptions) => element?.addEventListener('wheel', listener); element?.addEventListener('touchmove', listener); + // use capture phase because scroll does not bubble + element?.addEventListener('scroll', listener, true); return () => { element?.removeEventListener('wheel', listener); element?.removeEventListener('touchmove', listener); + element?.removeEventListener('scroll', listener, true); }; }, [listener, element, disabled]); };