diff --git a/README.md b/README.md
index 26024f3..eaf2b20 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](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');
+});