diff --git a/README.md b/README.md index 26024f3..eaf2b20 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Greenkeeper badge](https://badges.greenkeeper.io/sparksuite/react-accessible-dropdown-menu-hook.svg)](https://greenkeeper.io/) -This Hook handles all the accessibility logic when building a dropdown menu, dropdown button, etc., and leaves the design completely up to you. [View the demo.](http://sparksuite.github.io/react-accessible-dropdown-menu-hook) +This Hook handles all the accessibility logic when building a dropdown menu, dropdown button, etc., and leaves the design completely up to you. It also handles the logic for closing the menu when you click outside of it. [View the demo.](http://sparksuite.github.io/react-accessible-dropdown-menu-hook) ## Getting started diff --git a/src/use-dropdown-menu.ts b/src/use-dropdown-menu.ts index f58c2d8..98cb9cb 100644 --- a/src/use-dropdown-menu.ts +++ b/src/use-dropdown-menu.ts @@ -39,6 +39,36 @@ export default function useDropdownMenu(itemCount: number) { if (isOpen) { moveFocus(0); } + + // This function is designed to handle every click + const handleEveryClick = (event: MouseEvent) => { + // Ignore if the menu isn't open + if (!isOpen) { + return; + } + + // Make this happen asynchronously + setTimeout(() => { + // Type guard + if (!(event.target instanceof Element)) { + return; + } + + // Ignore if we're clicking inside the menu + if (event.target.closest('[role="menu"]')) { + return; + } + + // Hide dropdown + setIsOpen(false); + }, 10); + }; + + // Add listener + document.addEventListener('click', handleEveryClick); + + // Return function to remove listener + return () => document.removeEventListener('click', handleEveryClick); }, [isOpen]); // Create a handler function for the button's clicks and keyboard events diff --git a/test/puppeteer/demo.test.ts b/test/puppeteer/demo.test.ts index 1e2a060..f39089f 100644 --- a/test/puppeteer/demo.test.ts +++ b/test/puppeteer/demo.test.ts @@ -6,6 +6,8 @@ const { keyboard } = page; // Helper functions used in multiple tests const currentFocusID = () => page.evaluate(() => document.activeElement.id); +const menuOpen = () => page.waitForSelector('#menu', { visible: true }); +const menuClosed = () => page.waitForSelector('#menu', { hidden: true }); // Tests beforeEach(async () => { @@ -21,31 +23,63 @@ it('has the correct page title', async () => { it('focuses on the first menu item when the enter key is pressed', async () => { await page.focus('#menu-button'); await keyboard.down('Enter'); + await menuOpen(); expect(await currentFocusID()).toBe('menu-item-1'); }); it('focuses on the menu button after pressing escape', async () => { - await page.focus('#menu-button'); - await keyboard.down('Enter'); + await page.click('#menu-button'); + await menuOpen(); + await keyboard.down('Escape'); + await menuClosed(); expect(await currentFocusID()).toBe('menu-button'); }); it('focuses on the next item in the tab order after pressing tab', async () => { - await page.focus('#menu-button'); - await keyboard.down('Enter'); + await page.click('#menu-button'); + await menuOpen(); + await keyboard.down('Tab'); + await menuClosed(); expect(await currentFocusID()).toBe('first-footer-link'); }); it('focuses on the previous item in the tab order after pressing shift-tab', async () => { - await page.focus('#menu-button'); - await keyboard.down('Enter'); + await page.click('#menu-button'); + await menuOpen(); + await keyboard.down('Shift'); await keyboard.down('Tab'); + await menuClosed(); expect(await currentFocusID()).toBe('menu-button'); }); + +it('closes the menu if you click outside of it', async () => { + await page.click('#menu-button'); + await menuOpen(); + + await page.click('body'); + await menuClosed(); // times out if menu doesn't close + + expect(true).toBe(true); +}); + +it('leaves the menu open if you click inside of it', async () => { + await page.click('#menu-button'); + await menuOpen(); + + await page.click('#menu-item-1'); + await new Promise(resolve => setTimeout(resolve, 1000)); // visibility: hidden is delayed via CSS + await menuOpen(); // times out if menu closes + + await page.click('#menu'); + await new Promise(resolve => setTimeout(resolve, 1000)); // visibility: hidden is delayed via CSS + await menuOpen(); // times out if menu closes + + expect(true).toBe(true); +}); diff --git a/test/use-dropdown-menu.test.tsx b/test/use-dropdown-menu.test.tsx index fb84078..47ea6a2 100644 --- a/test/use-dropdown-menu.test.tsx +++ b/test/use-dropdown-menu.test.tsx @@ -119,3 +119,24 @@ it('moves the focus to the menu button after pressing escape while focused on a firstMenuItem.simulate('keydown', { key: 'Escape' }); expect(document.activeElement?.id).toBe('menu-button'); }); + +it('opens the menu after clicking the button', () => { + const component = mount(); + const button = component.find('#menu-button'); + const span = component.find('#is-open-indicator'); + + button.simulate('click'); + + expect(span.text()).toBe('true'); +}); + +it('closes the menu after clicking the button when the menu is open', () => { + const component = mount(); + const button = component.find('#menu-button'); + const span = component.find('#is-open-indicator'); + + button.simulate('click'); + button.simulate('click'); + + expect(span.text()).toBe('false'); +});