diff --git a/joi/src/hooks/useClickOutside/index.tsx b/joi/src/hooks/useClickOutside/index.tsx index 75e2400cf..af47ba484 100644 --- a/joi/src/hooks/useClickOutside/index.tsx +++ b/joi/src/hooks/useClickOutside/index.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useRef } from 'react' const DEFAULT_EVENTS = ['mousedown', 'touchstart'] @@ -8,34 +7,43 @@ export function useClickOutside( events?: string[] | null, nodes?: (HTMLElement | null)[] ) { - const ref = useRef() + const ref = useRef(null) useEffect(() => { - const listener = (event: any) => { - const { target } = event ?? {} + const listener = (event: Event) => { + const target = event.target as HTMLElement + + // Check if the target or any ancestor has the data-ignore-outside-clicks attribute + const shouldIgnore = + target.closest('[data-ignore-outside-clicks]') !== null + if (Array.isArray(nodes)) { - const shouldIgnore = - target?.hasAttribute('data-ignore-outside-clicks') || - (!document.body.contains(target) && target.tagName !== 'HTML') const shouldTrigger = nodes.every( (node) => !!node && !event.composedPath().includes(node) ) - shouldTrigger && !shouldIgnore && handler() - } else if (ref.current && !ref.current.contains(target)) { + if (shouldTrigger && !shouldIgnore) { + handler() + } + } else if ( + ref.current && + !ref.current.contains(target) && + !shouldIgnore + ) { handler() } } - ;(events || DEFAULT_EVENTS).forEach((fn) => - document.addEventListener(fn, listener) + const eventList = events || DEFAULT_EVENTS + eventList.forEach((event) => + document.documentElement.addEventListener(event, listener) ) return () => { - ;(events || DEFAULT_EVENTS).forEach((fn) => - document.removeEventListener(fn, listener) + eventList.forEach((event) => + document.documentElement.removeEventListener(event, listener) ) } - }, [ref, handler, nodes]) + }, [handler, nodes, events]) return ref } diff --git a/joi/src/hooks/useClickOutside/useClickOutside.test.tsx b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx index ac73b280a..8997721cd 100644 --- a/joi/src/hooks/useClickOutside/useClickOutside.test.tsx +++ b/joi/src/hooks/useClickOutside/useClickOutside.test.tsx @@ -1,55 +1,84 @@ import React from 'react' -import { render, fireEvent, act } from '@testing-library/react' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' import { useClickOutside } from './index' -// Mock component to test the hook -const TestComponent: React.FC<{ onClickOutside: () => void }> = ({ - onClickOutside, +const TestComponent = ({ + handler, + nodes, +}: { + handler: () => void + nodes?: (HTMLElement | null)[] }) => { - const ref = useClickOutside(onClickOutside) - return
}>Test
+ const ref = useClickOutside(handler, undefined, nodes) + + return ( +
+ Click me +
+ ) } -describe('@joi/hooks/useClickOutside', () => { - it('should call handler when clicking outside', () => { - const handleClickOutside = jest.fn() - const { container } = render( - - ) +describe('useClickOutside', () => { + afterEach(cleanup) - act(() => { - fireEvent.mouseDown(document.body) - }) + it('should call handler when clicking outside the element', () => { + const handler = jest.fn() + render() - expect(handleClickOutside).toHaveBeenCalledTimes(1) + fireEvent.mouseDown(document.body) + expect(handler).toHaveBeenCalledTimes(1) }) - it('should not call handler when clicking inside', () => { - const handleClickOutside = jest.fn() - const { getByText } = render( - - ) + it('should not call handler when clicking inside the element', () => { + const handler = jest.fn() + render() - act(() => { - fireEvent.mouseDown(getByText('Test')) - }) - - expect(handleClickOutside).not.toHaveBeenCalled() + fireEvent.mouseDown(screen.getByTestId('clickable')) + expect(handler).not.toHaveBeenCalled() }) - it('should work with custom events', () => { - const handleClickOutside = jest.fn() - const TestComponentWithCustomEvent: React.FC = () => { - const ref = useClickOutside(handleClickOutside, ['click']) - return
}>Test
- } + it('should not call handler if target has data-ignore-outside-clicks attribute', () => { + const handler = jest.fn() + render( + <> + +
Ignore this
+ + ) - render() + // Ensure that the div with the attribute is correctly queried + fireEvent.mouseDown(screen.getByText('Ignore this')) + expect(handler).not.toHaveBeenCalled() + }) - act(() => { - fireEvent.click(document.body) - }) + it('should call handler when clicking outside if nodes is an empty array', () => { + const handler = jest.fn() + render() - expect(handleClickOutside).toHaveBeenCalledTimes(1) + fireEvent.mouseDown(document.body) + expect(handler).toHaveBeenCalledTimes(1) + }) + + it('should not call handler if clicking inside nodes', () => { + const handler = jest.fn() + const node = document.createElement('div') + document.body.appendChild(node) + + render( + <> + + + ) + + fireEvent.mouseDown(node) + expect(handler).not.toHaveBeenCalled() + }) + + it('should call handler if nodes is undefined', () => { + const handler = jest.fn() + render() + + fireEvent.mouseDown(document.body) + expect(handler).toHaveBeenCalledTimes(1) }) }) diff --git a/joi/src/hooks/useMediaQuery/index.ts b/joi/src/hooks/useMediaQuery/index.ts index 03010fc78..31b548db0 100644 --- a/joi/src/hooks/useMediaQuery/index.ts +++ b/joi/src/hooks/useMediaQuery/index.ts @@ -23,7 +23,7 @@ function attachMediaListener( } } -function getInitialValue(query: string, initialValue?: boolean) { +export function getInitialValue(query: string, initialValue?: boolean) { if (typeof initialValue === 'boolean') { return initialValue } diff --git a/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts index 5813bd41d..1d0fa20be 100644 --- a/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts +++ b/joi/src/hooks/useMediaQuery/useMediaQuery.test.ts @@ -1,5 +1,8 @@ import { renderHook, act } from '@testing-library/react' -import { useMediaQuery } from './index' +import { useMediaQuery, getInitialValue } from './index' + +const global = globalThis +const originalWindow = global.window describe('@joi/hooks/useMediaQuery', () => { const matchMediaMock = jest.fn() @@ -10,6 +13,39 @@ describe('@joi/hooks/useMediaQuery', () => { afterEach(() => { matchMediaMock.mockClear() + global.window = originalWindow + }) + + it('should return undetermined when window is undefined', () => { + delete (global as any).window + expect(getInitialValue('(max-width: 600px)', true)).toBe(true) + expect(getInitialValue('(max-width: 600px)', false)).toBe(false) + }) + + it('should return default return false', () => { + delete (global as any).window + expect(getInitialValue('(max-width: 600px)')).toBe(false) + }) + + it('should return matchMedia result when window is defined and matchMedia exists', () => { + // Mock window.matchMedia + const matchMediaMock = jest.fn().mockImplementation((query) => ({ + matches: query === '(max-width: 600px)', + media: query, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })) + + // Mock window and matchMedia + ;(global as any).window = { matchMedia: matchMediaMock } + + // Test the function behavior + expect(getInitialValue('(max-width: 600px)')).toBe(true) // Query should match + expect(matchMediaMock).toHaveBeenCalledWith('(max-width: 600px)') + + // Test with a non-matching query + expect(getInitialValue('(min-width: 1200px)')).toBe(false) // Query should not match + expect(matchMediaMock).toHaveBeenCalledWith('(min-width: 1200px)') }) it('should return initial value when getInitialValueInEffect is true', () => { @@ -87,4 +123,38 @@ describe('@joi/hooks/useMediaQuery', () => { expect(result.current).toBe(true) }) + + it('should return undefined when matchMedia is not available', () => { + delete (global as any).window.matchMedia + + const { result } = renderHook(() => useMediaQuery('(max-width: 600px)')) + expect(result.current).toBe(undefined) + }) + + it('should use initialValue when getInitialValueInEffect is true', () => { + const { result } = renderHook(() => + useMediaQuery('(max-width: 600px)', true, { + getInitialValueInEffect: true, + }) + ) + expect(result.current).toBe(true) + }) + + it('should use getInitialValue when getInitialValueInEffect is false', () => { + const { result } = renderHook(() => + useMediaQuery('(max-width: 600px)', undefined, { + getInitialValueInEffect: false, + }) + ) + expect(result.current).toBe(false) + }) + + it('should use initialValue as false when getInitialValueInEffect is true', () => { + const { result } = renderHook(() => + useMediaQuery('(max-width: 600px)', false, { + getInitialValueInEffect: true, + }) + ) + expect(result.current).toBe(false) + }) }) diff --git a/joi/src/hooks/useOs/index.tsx b/joi/src/hooks/useOs/index.tsx index fb7fd9028..12e3d2410 100644 --- a/joi/src/hooks/useOs/index.tsx +++ b/joi/src/hooks/useOs/index.tsx @@ -8,7 +8,7 @@ export type OS = | 'android' | 'linux' -function getOS(): OS { +export function getOS(): OS { if (typeof window === 'undefined') { return 'undetermined' } diff --git a/joi/src/hooks/useOs/useOs.test.ts b/joi/src/hooks/useOs/useOs.test.ts index 037640b5e..b66ad1519 100644 --- a/joi/src/hooks/useOs/useOs.test.ts +++ b/joi/src/hooks/useOs/useOs.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react' -import { useOs } from './index' +import { useOs, getOS } from './index' +import '@testing-library/jest-dom' const platforms = { windows: [ @@ -21,10 +22,28 @@ const platforms = { } as const describe('@joi/hooks/useOS', () => { + const global = globalThis + const originalWindow = global.window + afterEach(() => { + global.window = originalWindow jest.clearAllMocks() }) + it('should return undetermined when window is undefined', () => { + delete (global as any).window + expect(getOS()).toBe('undetermined') + }) + + it('should return undetermined when getValueInEffect is false', () => { + jest + .spyOn(window.navigator, 'userAgent', 'get') + .mockReturnValueOnce('UNKNOWN_USER_AGENT') + + const { result } = renderHook(() => useOs({ getValueInEffect: false })) + expect(result.current).toBe('undetermined') + }) + Object.entries(platforms).forEach(([os, userAgents]) => { it.each(userAgents)(`should detect %s platform on ${os}`, (userAgent) => { jest diff --git a/joi/src/index.test.ts b/joi/src/index.test.ts new file mode 100644 index 000000000..8bfba8d93 --- /dev/null +++ b/joi/src/index.test.ts @@ -0,0 +1,43 @@ +import * as components from './index' + +// Mock styles globally for all components in this test +jest.mock('./core/Tooltip/styles.scss', () => ({})) +jest.mock('./core/ScrollArea/styles.scss', () => ({})) +jest.mock('./core/Button/styles.scss', () => ({})) +jest.mock('./core/Switch/styles.scss', () => ({})) +jest.mock('./core/Progress/styles.scss', () => ({})) +jest.mock('./core/Checkbox/styles.scss', () => ({})) +jest.mock('./core/Badge/styles.scss', () => ({})) +jest.mock('./core/Modal/styles.scss', () => ({})) +jest.mock('./core/Slider/styles.scss', () => ({})) +jest.mock('./core/Input/styles.scss', () => ({})) +jest.mock('./core/Select/styles.scss', () => ({})) +jest.mock('./core/TextArea/styles.scss', () => ({})) +jest.mock('./core/Tabs/styles.scss', () => ({})) +jest.mock('./core/Accordion/styles.scss', () => ({})) + +describe('Exports', () => { + it('exports all components and hooks', () => { + expect(components.Tooltip).toBeDefined() + expect(components.ScrollArea).toBeDefined() + expect(components.Button).toBeDefined() + expect(components.Switch).toBeDefined() + expect(components.Progress).toBeDefined() + expect(components.Checkbox).toBeDefined() + expect(components.Badge).toBeDefined() + expect(components.Modal).toBeDefined() + expect(components.Slider).toBeDefined() + expect(components.Input).toBeDefined() + expect(components.Select).toBeDefined() + expect(components.TextArea).toBeDefined() + expect(components.Tabs).toBeDefined() + expect(components.Accordion).toBeDefined() + + expect(components.useClipboard).toBeDefined() + expect(components.usePageLeave).toBeDefined() + expect(components.useTextSelection).toBeDefined() + expect(components.useClickOutside).toBeDefined() + expect(components.useOs).toBeDefined() + expect(components.useMediaQuery).toBeDefined() + }) +})