diff --git a/client/components/dropdown/dropdown.behavior.test.jsx b/client/components/dropdown/dropdown.behavior.test.jsx new file mode 100644 index 000000000..51387a436 --- /dev/null +++ b/client/components/dropdown/dropdown.behavior.test.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Dropdown } from './dropdown.jsx'; +import { createOpenMenu, renderDropdown, setupDropdownTestLifecycle } from './dropdown.testUtils.js'; + +setupDropdownTestLifecycle(); + +describe('Dropdown behavior', ()=>{ + it('calls child click handlers when menu items are clicked', async ()=>{ + const onClick = jest.fn(); + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + expect(onClick).toHaveBeenCalledTimes(1); + + await cleanup(); + }); + + it('dismisses open menus when clicking a normal menu action', async ()=>{ + const openMenu1 = createOpenMenu(); + const openMenu2 = createOpenMenu(); + const querySpy = jest.spyOn(document, 'querySelectorAll').mockImplementation((selector)=>{ + if(selector === '.menu-list:popover-open') return [openMenu1, openMenu2]; + return []; + }); + + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + + expect(querySpy).toHaveBeenCalledWith('.menu-list:popover-open'); + expect(openMenu1.hidePopover).toHaveBeenCalledTimes(1); + expect(openMenu2.hidePopover).toHaveBeenCalledTimes(1); + + await cleanup(); + }); + + it('does not dismiss when no-dismiss is set to empty string or true', async ()=>{ + for (const noDismissValue of ['', 'true']) { + const openMenu = createOpenMenu(); + const querySpy = jest.spyOn(document, 'querySelectorAll').mockImplementation((selector)=>{ + if(selector === '.menu-list:popover-open') return [openMenu]; + return []; + }); + + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + expect(querySpy).not.toHaveBeenCalled(); + expect(openMenu.hidePopover).not.toHaveBeenCalled(); + + await cleanup(); + querySpy.mockRestore(); + } + }); + + it('does dismiss when no-dismiss is explicitly false', async ()=>{ + const openMenu = createOpenMenu(); + const querySpy = jest.spyOn(document, 'querySelectorAll').mockImplementation((selector)=>{ + if(selector === '.menu-list:popover-open') return [openMenu]; + return []; + }); + + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + + expect(querySpy).toHaveBeenCalledWith('.menu-list:popover-open'); + expect(openMenu.hidePopover).toHaveBeenCalledTimes(1); + + await cleanup(); + }); + + it('does not dismiss when clicking submenu trigger actions', async ()=>{ + const querySpy = jest.spyOn(document, 'querySelectorAll'); + const { host, cleanup } = await renderDropdown( + + + + + + ); + + const submenuTrigger = host.querySelectorAll('.menu-wrapper > .menu-item')[1]; + submenuTrigger.click(); + + expect(submenuTrigger.hasAttribute('popovertarget')).toBe(true); + expect(querySpy).not.toHaveBeenCalledWith('.menu-list:popover-open'); + + await cleanup(); + }); + + it('handles iframe-click by hiding the open popover for this menu', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const menu = host.querySelector('.menu-list'); + menu.matches = jest.fn((selector)=>selector === ':popover-open'); + menu.hidePopover = jest.fn(); + + document.dispatchEvent(new Event('iframe-click')); + + expect(menu.matches).toHaveBeenCalledWith(':popover-open'); + expect(menu.hidePopover).toHaveBeenCalledTimes(1); + + await cleanup(); + }); +}); diff --git a/client/components/dropdown/dropdown.structure.test.jsx b/client/components/dropdown/dropdown.structure.test.jsx new file mode 100644 index 000000000..ceacfe4af --- /dev/null +++ b/client/components/dropdown/dropdown.structure.test.jsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Dropdown } from './dropdown.jsx'; +import { renderDropdown, setupDropdownTestLifecycle } from './dropdown.testUtils.js'; + +setupDropdownTestLifecycle(); + +describe('Dropdown structure', ()=>{ + it('renders expected structure, classes, and accessibility roles', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const wrapper = host.querySelector('.menu-wrapper'); + const trigger = wrapper.querySelector('button.menu-item.blue'); + const menu = wrapper.querySelector('.menu-list'); + + expect(wrapper).toBeTruthy(); + expect(wrapper.className).toContain('extra-class'); + expect(wrapper.getAttribute('role')).toBe('none'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('role')).toBe('menuitem'); + expect(menu.getAttribute('role')).toBe('menu'); + expect(menu.getAttribute('popover')).toBe('auto'); + + await cleanup(); + }); + + it('creates ids and popovertarget values that match and are unique per instance', async ()=>{ + const { host, cleanup } = await renderDropdown( +
+ + + + + + +
+ ); + + const triggers = Array.from(host.querySelectorAll('.menu-wrapper > .menu-item')); + const menus = Array.from(host.querySelectorAll('.menu-list')); + const menuIds = menus.map((menu)=>menu.id); + + expect(triggers).toHaveLength(2); + expect(menus).toHaveLength(2); + expect(new Set(menuIds).size).toBe(2); + expect(triggers[0].getAttribute('popovertarget')).toBe(menuIds[0]); + expect(triggers[1].getAttribute('popovertarget')).toBe(menuIds[1]); + expect(triggers[0].id).toBe(`${menuIds[0]}-trigger`); + + await cleanup(); + }); + + it('applies css anchor styles to trigger and menu', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const trigger = host.querySelector('.menu-wrapper > .menu-item'); + const menu = host.querySelector('.menu-list'); + const menuId = menu.id; + + expect(trigger.getAttribute('style')).toBe(`anchor-name: --${menuId}`); + expect(menu.getAttribute('style')).toBe(`position-anchor: --${menuId}`); + + await cleanup(); + }); + + it('disables the trigger when no children are present', async ()=>{ + const { host, cleanup } = await renderDropdown(); + + expect(host.querySelector('.menu-wrapper > .menu-item').disabled).toBe(true); + + await cleanup(); + }); + + it('renders icon class and default caret', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const trigger = host.querySelector('.menu-wrapper > .menu-item'); + expect(trigger.querySelector('i.fas.fa-star')).toBeTruthy(); + expect(trigger.querySelector('i.caret').className).toContain('fa-caret-down'); + + await cleanup(); + }); + + it('renders a custom trigger when provided', async ()=>{ + const { host, cleanup } = await renderDropdown( + Open Menu}> + + + ); + + const trigger = host.querySelector('.menu-wrapper > .menu-item'); + expect(trigger.querySelector('.custom-trigger')).toBeTruthy(); + expect(trigger.querySelector('.caret')).toBeFalsy(); + + await cleanup(); + }); + + it('supports nested submenus to at least 3 levels deep', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + + + + + ); + + const triggers = Array.from(host.querySelectorAll('.menu-wrapper > .menu-item')); + const menus = Array.from(host.querySelectorAll('.menu-list')); + + expect(triggers).toHaveLength(3); + expect(menus).toHaveLength(3); + + for (const trigger of triggers) { + const targetId = trigger.getAttribute('popovertarget'); + expect(targetId).toBeTruthy(); + expect(host.querySelector(`#${targetId}`)).toBeTruthy(); + } + + expect(triggers[1].querySelector('.caret').className).toContain('fa-caret-right'); + expect(triggers[2].querySelector('.caret').className).toContain('fa-caret-right'); + + await cleanup(); + }); +}); diff --git a/client/components/dropdown/dropdown.testUtils.js b/client/components/dropdown/dropdown.testUtils.js new file mode 100644 index 000000000..f7467bea7 --- /dev/null +++ b/client/components/dropdown/dropdown.testUtils.js @@ -0,0 +1,49 @@ +import globalJsdom from 'jsdom-global'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +let cleanupJsdom; + +const setupDropdownTestLifecycle = ()=>{ + beforeAll(()=>{ + cleanupJsdom = globalJsdom(); + }); + + afterAll(()=>{ + cleanupJsdom?.(); + }); + + afterEach(()=>{ + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); +}; + +const renderDropdown = async (element)=>{ + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async ()=>{ + root.render(element); + }); + + return { + host, + cleanup : async ()=>{ + await act(async ()=>{ + root.unmount(); + }); + host.remove(); + } + }; +}; + +const createOpenMenu = ()=>{ + const menu = document.createElement('div'); + menu.className = 'menu-list'; + menu.hidePopover = jest.fn(); + return menu; +}; + +export { setupDropdownTestLifecycle, renderDropdown, createOpenMenu }; diff --git a/package.json b/package.json index 7ed436d6d..9b8a2d937 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:coverage": "jest --coverage --silent --runInBand", "test:dev": "jest --verbose --watch", "test:basic": "jest tests/markdown/basic.test.js --verbose", + "test:components": "jest \"client/components/.*\\.test\\.(js|jsx)$\" --verbose", "test:variables": "jest tests/markdown/variables.test.js --verbose", "test:mustache-syntax": "jest \".*(mustache-syntax).*\" --verbose --noStackTrace", "test:mustache-syntax:inline": "jest \".*(mustache-syntax).*\" -t '^Inline:.*' --verbose --noStackTrace", @@ -61,9 +62,12 @@ "node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools|entities)/)" ], "transform": { - "^.+\\.[jt]s$": "babel-jest", + "^.+\\.[jt]sx?$": "babel-jest", "^.+\\.mjs$": "babel-jest" }, + "moduleNameMapper": { + "\\.(less|css)$": "/tests/__mocks__/styleMock.js" + }, "coveragePathIgnorePatterns": [ "build/*" ], diff --git a/tests/__mocks__/styleMock.js b/tests/__mocks__/styleMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/tests/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {};