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 = {};