diff --git a/package.json b/package.json index b458fc6..5280c29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-accessible-dropdown-menu-hook", - "version": "2.3.1", + "version": "3.0.0", "description": "A simple Hook for creating fully accessible dropdown menus in React", "main": "dist/use-dropdown-menu.js", "types": "dist/use-dropdown-menu.d.ts", diff --git a/src/use-dropdown-menu.test.tsx b/src/use-dropdown-menu.test.tsx index 8a52842..7bcf238 100644 --- a/src/use-dropdown-menu.test.tsx +++ b/src/use-dropdown-menu.test.tsx @@ -1,13 +1,17 @@ // Imports import React, { useState } from 'react'; -import useDropdownMenu from './use-dropdown-menu'; +import useDropdownMenu, { DropdownMenuOptions } from './use-dropdown-menu'; import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; // A mock component for testing the Hook -const TestComponent: React.FC = () => { +interface Props { + options?: DropdownMenuOptions; +} + +const TestComponent: React.FC = ({ options }) => { const [itemCount, setItemCount] = useState(4); - const { buttonProps, itemProps, isOpen, setIsOpen } = useDropdownMenu(itemCount); + const { buttonProps, itemProps, isOpen, setIsOpen } = useDropdownMenu(itemCount, options); const clickHandlers: (() => void)[] = [(): void => console.log('Item one clicked'), (): void => setIsOpen(false)]; @@ -79,11 +83,19 @@ it('Moves the focus to the first menu item after pressing space while focused on expect(screen.getByText('1 Item')).toHaveFocus(); }); -it('Moves the focus to the first menu item after clicking the menu to open it, then pressing tab while focused on the menu button', () => { +it('Moves the focus to the first menu item after clicking the menu to open it', () => { render(); userEvent.click(screen.getByText('Primary')); + expect(screen.getByText('1 Item')).toHaveFocus(); +}); + +it('Moves the focus to the first menu item after clicking the menu to open it, then pressing tab while focused on the menu button, if `disableFocusFirstItemOnClick` is specified', () => { + render(); + + userEvent.click(screen.getByText('Primary')); + expect(screen.getByText('Primary')).toHaveFocus(); userEvent.tab(); @@ -91,8 +103,8 @@ it('Moves the focus to the first menu item after clicking the menu to open it, t expect(screen.getByText('1 Item')).toHaveFocus(); }); -it('Moves the focus to the first menu item after clicking the menu to open it, then pressing arrow down while focused on the menu button', () => { - render(); +it('Moves the focus to the first menu item after clicking the menu to open it, then pressing arrow down while focused on the menu button, if `disableFocusFirstItemOnClick` is specified', () => { + render(); userEvent.click(screen.getByText('Primary')); diff --git a/src/use-dropdown-menu.ts b/src/use-dropdown-menu.ts index 512f10f..973105d 100644 --- a/src/use-dropdown-menu.ts +++ b/src/use-dropdown-menu.ts @@ -11,6 +11,10 @@ interface ButtonProps } // A custom Hook that abstracts away the listeners/controls for dropdown menus +export interface DropdownMenuOptions { + disableFocusFirstItemOnClick?: boolean; +} + interface DropdownMenuResponse { readonly buttonProps: ButtonProps; readonly itemProps: { @@ -23,7 +27,7 @@ interface DropdownMenuResponse { readonly setIsOpen: React.Dispatch>; } -export default function useDropdownMenu(itemCount: number): DropdownMenuResponse { +export default function useDropdownMenu(itemCount: number, options?: DropdownMenuOptions): DropdownMenuResponse { // Use state const [isOpen, setIsOpen] = useState(false); const currentFocusIndex = useRef(null); @@ -56,7 +60,7 @@ export default function useDropdownMenu(itemCount: number): DropdownMenuResponse } // If the menu is currently open focus on the first item in the menu - if (isOpen && !clickedOpen.current) { + if (isOpen && !options?.disableFocusFirstItemOnClick) { moveFocus(0); } else if (!isOpen) { clickedOpen.current = false; @@ -137,7 +141,10 @@ export default function useDropdownMenu(itemCount: number): DropdownMenuResponse setIsOpen(false); } } else { - clickedOpen.current = !isOpen; + if (options?.disableFocusFirstItemOnClick) { + clickedOpen.current = !isOpen; + } + setIsOpen(!isOpen); } }; diff --git a/test-projects/browser/src/app.test.ts b/test-projects/browser/src/app.test.ts index 9465d72..cbc21f2 100644 --- a/test-projects/browser/src/app.test.ts +++ b/test-projects/browser/src/app.test.ts @@ -35,11 +35,11 @@ it('Has the correct page title', async () => { await expect(page.title()).resolves.toMatch('Browser'); }); -it('Leaves focus on the button after clicking it', async () => { +it('Focuses the first menu item when menu button is clicked', async () => { await page.click('#menu-button'); await menuOpen(); - expect(await currentFocusID()).toBe('menu-button'); + expect(await currentFocusID()).toBe('menu-item-1'); }); it('Focuses on the menu button after pressing escape', async () => { diff --git a/website/docs/design/options.md b/website/docs/design/options.md new file mode 100644 index 0000000..12a8d95 --- /dev/null +++ b/website/docs/design/options.md @@ -0,0 +1,19 @@ +--- +title: Options +--- + +You can customize the behavior with options, passed as the second argument. + +Option | Default | Possible values +:--- | :--- | :--- +`disableFocusFirstItemOnClick` | `false` | `boolean` + +Option | Explanation +:--- | :--- +`disableFocusFirstItemOnClick` | If specified as `true` the default behavior of focusing the first menu item on click will be disabled. The menu button will instead retain focus. + +```js +const { buttonProps, itemProps, isOpen, setIsOpen } = useDropdownMenu(numberOfItems, { + disableFocusFirstItemOnClick: true, +}); +``` \ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index 3b22f8f..65a5011 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -1,6 +1,6 @@ module.exports = { default: { 'Getting started': ['getting-started/install', 'getting-started/import', 'getting-started/using'], - Design: ['design/return-object', 'design/accessibility'], + Design: ['design/return-object', 'design/accessibility', 'design/options'], }, }; diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 6603d4f..7c30838 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -110,10 +110,19 @@ text-decoration: underline; } +/* Fallback for browsers that do not support :focus-visible. */ .demo-menu a:focus { border-color: #3a8eb8; } +.demo-menu a:focus:not(:focus-visible) { + border-color: transparent; +} + +.demo-menu a:focus-visible { + border-color: #3a8eb8; +} + .demo-menu a svg { color: #777; margin-right: 0.5rem; diff --git a/website/src/pages/demo.tsx b/website/src/pages/demo.tsx index e0a0ddc..755cae7 100644 --- a/website/src/pages/demo.tsx +++ b/website/src/pages/demo.tsx @@ -65,12 +65,7 @@ const Demo: React.FC = () => { The menu can be revealed by clicking the button, or by focusing the button and pressing enter / space -
  • If the menu is revealed with the keyboard, the first menu item is automatically focused
  • -
  • - If the menu is revealed with the mouse, the first menu item can be focused by pressing tab / arrow - down -
  • -
  • If the menu is revealed with the mouse, the menu can be be closed by pressing escape
  • +
  • When the menu is revealed, the first menu item is automatically focused
  • Once focus is in the menu…