From ee1064ab98025ab4021483b7eeef544fb3a3ae82 Mon Sep 17 00:00:00 2001 From: renrizzolo Date: Tue, 20 Dec 2022 14:25:13 +1100 Subject: [PATCH] feat: focus utils --- scripts/generate-types/generateTypes.js | 2 +- src/components/ActionPanel/index.d.ts | 7 + src/components/ActionPanel/index.jsx | 65 +++- src/components/ActionPanel/index.spec.jsx | 135 ++++++++ .../__snapshots__/index.spec.jsx.snap | 81 ++--- .../DismissibleFocusTrap/index.d.ts | 25 ++ src/components/DismissibleFocusTrap/index.jsx | 156 +++++++++ .../DismissibleFocusTrap/index.spec.jsx | 299 ++++++++++++++++++ src/hooks/index.js | 1 + src/hooks/useArrowFocus.js | 24 +- src/hooks/useArrowFocus.spec.js | 38 ++- src/hooks/useClickOutside.js | 24 ++ src/utils/focus/index.js | 96 ++++++ src/utils/focus/index.spec.js | 58 ++++ src/{utils.js => utils/index.js} | 0 www/containers/props.json | 87 +++++ www/examples/ActionPanel.mdx | 122 +++---- 17 files changed, 1084 insertions(+), 136 deletions(-) create mode 100644 src/components/DismissibleFocusTrap/index.d.ts create mode 100644 src/components/DismissibleFocusTrap/index.jsx create mode 100644 src/components/DismissibleFocusTrap/index.spec.jsx create mode 100644 src/hooks/useClickOutside.js create mode 100644 src/utils/focus/index.js create mode 100644 src/utils/focus/index.spec.js rename src/{utils.js => utils/index.js} (100%) diff --git a/scripts/generate-types/generateTypes.js b/scripts/generate-types/generateTypes.js index 5840000ec..c2ca46437 100644 --- a/scripts/generate-types/generateTypes.js +++ b/scripts/generate-types/generateTypes.js @@ -138,7 +138,7 @@ async function generateTypeDefs() { await Promise.all( parsed.map(async (code, i) => { const result = await generateFromSource(null, code, { - babylonPlugins: ['exportDefaultFrom', 'transformImports'], + babylonPlugins: ['exportDefaultFrom', 'transformImports', 'nullishCoalescingOperator'], }); const component = allComponents[i]; diff --git a/src/components/ActionPanel/index.d.ts b/src/components/ActionPanel/index.d.ts index 12d68930e..9de6b44e1 100644 --- a/src/components/ActionPanel/index.d.ts +++ b/src/components/ActionPanel/index.d.ts @@ -7,10 +7,17 @@ export interface ActionPanelProps { className?: string; size?: ActionPanelSize; onClose: (...args: any[]) => any; + /** + * @param event + * called before `onClose` is called, when pressing escape. + * can be prevented with `event.preventDefault()` + */ + onEscapeClose?: (...args: any[]) => any; children: React.ReactNode; actionButton?: React.ReactNode; cancelButton?: React.ReactNode; isModal?: boolean; + disableFocusTrap?: boolean; /** * Hides the modal with css, but keeps it mounted. * This should only be used if you need to launch an ActionPanel diff --git a/src/components/ActionPanel/index.jsx b/src/components/ActionPanel/index.jsx index 582e33b7d..60ed2830f 100644 --- a/src/components/ActionPanel/index.jsx +++ b/src/components/ActionPanel/index.jsx @@ -2,12 +2,26 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; import ReactDOM from 'react-dom'; +import DismissibleFocusTrap from '../DismissibleFocusTrap'; import { expandDts } from '../../utils'; import Button from '../Button'; import './styles.css'; const ActionPanel = React.forwardRef((props, ref) => { - const { title, className, size, onClose, children, visuallyHidden, actionButton, isModal, cancelButton, dts } = props; + const { + title, + className, + size, + onClose, + onEscapeClose, + children, + visuallyHidden, + actionButton, + isModal, + cancelButton, + disableFocusTrap, + dts, + } = props; const addBodyClass = (classname) => document.body.classList.add(classname); const removeBodyClass = (classname) => document.body.classList.remove(classname); @@ -21,6 +35,12 @@ const ActionPanel = React.forwardRef((props, ref) => { }; }, [isModal, visuallyHidden]); + const onEscapeHandler = (event) => { + onEscapeClose?.(event); + if (event.defaultPrevented) return; + onClose(); + }; + const defaultCancelButton = ( <> {actionButton ? ( @@ -52,25 +72,34 @@ const ActionPanel = React.forwardRef((props, ref) => { })} >
-
-
- {title} +
+
+ {title} +
+ + + {cancelButton ? cancelButton : defaultCancelButton} + {actionButton} + +
+
+ {children}
- - {cancelButton ? cancelButton : defaultCancelButton} - {actionButton} - -
-
- {children} -
+
@@ -85,10 +114,18 @@ ActionPanel.propTypes = { // large is intended to be used in a modal size: PropTypes.oneOf(['small', 'medium', 'large']), onClose: PropTypes.func.isRequired, + /** + * @param event + * called before `onClose` is called, when pressing escape. + * + * can be prevented with `event.preventDefault()` + */ + onEscapeClose: PropTypes.func, children: PropTypes.node.isRequired, actionButton: PropTypes.node, cancelButton: PropTypes.node, isModal: PropTypes.bool, + disableFocusTrap: PropTypes.bool, /** * Hides the modal with css, but keeps it mounted. * This should only be used if you need to launch an ActionPanel diff --git a/src/components/ActionPanel/index.spec.jsx b/src/components/ActionPanel/index.spec.jsx index 7314697f2..79039d806 100644 --- a/src/components/ActionPanel/index.spec.jsx +++ b/src/components/ActionPanel/index.spec.jsx @@ -1,9 +1,18 @@ import _ from 'lodash'; import React from 'react'; import { act, render, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import Button from '../Button'; import ActionPanel from '.'; +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + afterEach(cleanup); describe('', () => { @@ -60,12 +69,138 @@ describe('', () => { expect(document.body).not.toHaveClass('modal-open'); }); + it('should trap focus inside the modal', () => { + const { getAllByRole } = render( + + + + + ); + act(() => { + jest.runAllTimers(); + }); + + expect(getAllByRole('button').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab(); + jest.runAllTimers(); + }); + expect(getAllByRole('button').at(1)).toHaveFocus(); + act(() => { + userEvent.tab(); + jest.runAllTimers(); + }); + expect(getAllByRole('searchbox').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab(); + jest.runAllTimers(); + }); + expect(getAllByRole('button').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab({ shift: true }); + jest.runAllTimers(); + }); + + expect(getAllByRole('searchbox').at(0)).toHaveFocus(); + act(() => { + userEvent.tab({ shift: true }); + jest.runAllTimers(); + }); + + expect(getAllByRole('button').at(1)).toHaveFocus(); + + act(() => { + userEvent.tab({ shift: true }); + jest.runAllTimers(); + }); + expect(getAllByRole('button').at(0)).toHaveFocus(); + }); + + it('should call onEscapeClose', () => { + const onEscapeClose = jest.fn(); + render( + + + + + ); + + act(() => { + userEvent.tab(); + userEvent.keyboard('[Escape]'); + }); + expect(onEscapeClose).toBeCalledTimes(1); + }); + + it('should not close when call onEscapeClose prevents default', () => { + const onEscapeClose = (e) => e.preventDefault(); + const onClose = jest.fn(); + render( + + + + + ); + + act(() => { + userEvent.tab(); + userEvent.keyboard('[Escape]'); + }); + expect(onClose).not.toBeCalled(); + }); + it('should hide the modal with the visuallyHidden prop', () => { const { getByTestId } = render(); expect(getByTestId('action-panel-modal-wrapper')).toHaveClass('visually-hidden'); }); + it('should focus the originally focussed element when closing a nested action panel', () => { + const TestComponent = () => { + const [showNestedActionPanel, setShowNestedActionPanel] = React.useState(); + return ( + + - + - - -
-

- Are you sure? -

+ + Confirm + + + +
+
+

+ Are you sure? +

+
`; diff --git a/src/components/DismissibleFocusTrap/index.d.ts b/src/components/DismissibleFocusTrap/index.d.ts new file mode 100644 index 000000000..cc3a47cb1 --- /dev/null +++ b/src/components/DismissibleFocusTrap/index.d.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; + +export interface DismissibleFocusTrapProps { + /** + * loops the tab sequence + */ + loop?: boolean; + /** + * focus the first focussable element on mount + */ + focusOnMount?: boolean; + /** + * disable all behaviour + */ + disabled?: boolean; + onEscape?: (...args: any[]) => any; + onClickOutside?: (...args: any[]) => any; + onTabExit?: (...args: any[]) => any; + onShiftTabExit?: (...args: any[]) => any; + children?: React.ReactNode; +} + +declare const DismissibleFocusTrap: React.FC; + +export default DismissibleFocusTrap; diff --git a/src/components/DismissibleFocusTrap/index.jsx b/src/components/DismissibleFocusTrap/index.jsx new file mode 100644 index 000000000..34eca0bc4 --- /dev/null +++ b/src/components/DismissibleFocusTrap/index.jsx @@ -0,0 +1,156 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { getFocusableNodes } from '../../utils/focus'; +import { useClickOutside } from '../../hooks'; + +const createFocusLayers = () => { + const layers = new Set(); + return { + add: (n) => layers.add(n), + delete: (n) => layers.delete(n), + isHighestLayer: (el) => { + const layersArr = Array.from(layers); + return layersArr.indexOf(el) === Math.max(layers.size - 1, 0); + }, + }; +}; + +export const focusLayers = createFocusLayers(); + +const DismissibleFocusTrap = ({ + loop = true, + focusOnMount = true, + disabled, + onEscape, + onClickOutside, + onTabExit, + onShiftTabExit, + children, + ...rest +}) => { + const contentRef = React.useRef(); + const clickedOutsideRef = React.useRef(); + const clickOutsideHandler = React.useCallback( + (event) => { + if (disabled) return; + if (event.defaultPrevented) return; + + if (onClickOutside) { + const isHighestLayer = focusLayers.isHighestLayer(contentRef.current); + + if (isHighestLayer) { + // don't steal focus if closing via clicking outside + clickedOutsideRef.current = true; + + onClickOutside?.(event); + event.stopPropagation(); + event.preventDefault(); + } + } + }, + [onClickOutside, disabled] + ); + + useClickOutside(contentRef, clickOutsideHandler); + + React.useEffect(() => { + if (disabled || !focusOnMount) return; + // handle focus on mount / focus previous focussed el on unmount + const previousFocusEl = document.activeElement; + const nodes = getFocusableNodes(contentRef.current, { tabbable: true }); + nodes[0]?.focus({ preventScroll: true }); + + return () => { + if (clickedOutsideRef.current) return; + // in some cases previousFocusEl isn't visible yet, so always focus it 'async' + window.requestAnimationFrame(() => { + previousFocusEl?.focus(); + }); + }; + }, [disabled, focusOnMount]); + + React.useEffect(() => { + if (disabled) return; + const node = contentRef.current; + node && focusLayers.add(node); + + return () => { + node && focusLayers.delete(node); + }; + }, [contentRef, disabled]); + + const handleKeyDown = React.useCallback( + (event) => { + if (disabled) return; + if (event.key === 'Tab') { + const currentFocusEl = document.activeElement; + const nodes = getFocusableNodes(contentRef?.current, { tabbable: true }); + const [first, ...other] = nodes; + let last = other.slice(-1)[0]; + + if (first) { + if (!last) last = first; + if (currentFocusEl === last && !event.shiftKey) { + event.preventDefault(); + if (onTabExit) return onTabExit?.(event, nodes); + loop && first?.focus(); + } + if (currentFocusEl === first && event.shiftKey) { + event.preventDefault(); + if (onTabExit) return onShiftTabExit?.(event, nodes); + loop && last?.focus(); + } + } + } + }, + [disabled, onTabExit, loop, onShiftTabExit] + ); + + React.useEffect(() => { + const onEscapeKeyDown = (event) => { + if (event.key === 'Escape') { + if (disabled) return; + if (event.defaultPrevented) return; + const isHighestLayer = focusLayers.isHighestLayer(contentRef.current); + + if (isHighestLayer) { + onEscape?.(event); + event.stopPropagation(); + event.preventDefault(); + } + } + }; + document.addEventListener('keydown', onEscapeKeyDown); + return () => { + document.removeEventListener('keydown', onEscapeKeyDown); + }; + }, [disabled, onEscape]); + + return ( +
+ {children} +
+ ); +}; + +DismissibleFocusTrap.propTypes = { + /** + * loops the tab sequence + */ + loop: PropTypes.bool, + /** + * focus the first focussable element on mount + */ + focusOnMount: PropTypes.bool, + /** + * disable all behaviour + */ + disabled: PropTypes.bool, + onEscape: PropTypes.func, + onClickOutside: PropTypes.func, + onTabExit: PropTypes.func, + onShiftTabExit: PropTypes.func, + children: PropTypes.node, +}; + +export default DismissibleFocusTrap; diff --git a/src/components/DismissibleFocusTrap/index.spec.jsx b/src/components/DismissibleFocusTrap/index.spec.jsx new file mode 100644 index 000000000..3d0d03cc5 --- /dev/null +++ b/src/components/DismissibleFocusTrap/index.spec.jsx @@ -0,0 +1,299 @@ +import React from 'react'; +import { act, render, cleanup, fireEvent, createEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import FocusTrap from '.'; + +beforeEach(() => { + jest.useFakeTimers(); +}); +afterEach(() => { + jest.useRealTimers(); +}); +afterEach(cleanup); + +describe('', () => { + it('should trap focus and loop', () => { + const { getAllByRole: getAllByRole1 } = render( +
+ +
+ ); + + // focus on this button before mounting FocusTrap, + // so we can assert the previously focussed element + // gets focus again after unmounting + getAllByRole1('button').at(0).focus(); + + const { getAllByRole, unmount } = render( + + + + + + ); + + act(() => { + userEvent.tab(); + }); + expect(getAllByRole('combobox').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab(); + }); + expect(getAllByRole('textbox').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab(); + }); + expect(getAllByRole('button').at(1)).toHaveFocus(); + + act(() => { + unmount(); + jest.runAllTimers(); + }); + expect(getAllByRole('button').at(0)).toHaveFocus(); + }); + + it('should trap focus even with one tabbable element', () => { + const { getAllByRole } = render( + + + + ); + act(() => { + userEvent.tab(); + userEvent.tab(); + userEvent.tab({ shift: true }); + userEvent.tab({ shift: true }); + }); + expect(getAllByRole('button').at(0)).toHaveFocus(); + }); + + it('should trap focus without looping', () => { + const { getAllByRole } = render( + + + + + ); + act(() => { + userEvent.tab(); + userEvent.tab(); + userEvent.tab(); + }); + expect(getAllByRole('button').at(1)).toHaveFocus(); + }); + + it('should not focus on mount when focusOnMount is false', () => { + const { getAllByRole } = render( + + + + ); + expect(getAllByRole('button').at(0)).not.toHaveFocus(); + + act(() => { + userEvent.tab(); + }); + expect(getAllByRole('button').at(0)).toHaveFocus(); + }); + + it('should call callback props', () => { + const onTabExit = jest.fn(); + const onShiftTabExit = jest.fn(); + render( + + + + + ); + + act(() => { + userEvent.tab(); + userEvent.tab(); + }); + + expect(onTabExit).toBeCalledTimes(1); + + act(() => { + userEvent.tab({ shift: true }); + userEvent.tab({ shift: true }); + }); + + expect(onShiftTabExit).toBeCalledTimes(1); + }); + + it('should not close on escape if default was prevented', () => { + const onEscape = jest.fn(); + const { getByTestId } = render( + + + + + ); + + act(() => { + userEvent.tab(); + userEvent.keyboard('[Escape]'); + }); + expect(onEscape).toBeCalledTimes(1); + + act(() => { + userEvent.tab(); + const evt = createEvent.keyDown(getByTestId('focus-trap'), { key: 'Escape' }); + evt.preventDefault(); + fireEvent(getByTestId('focus-trap'), evt); + }); + expect(onEscape).toBeCalledTimes(1); + }); + + it('should work with onClickOutside', () => { + const onClickOutside = jest.fn(); + const { getAllByRole, getByTestId } = render( +
+ + + + +
+ ); + + act(() => { + userEvent.tab(); + userEvent.click(getByTestId('outer')); + }); + expect(onClickOutside).toBeCalledTimes(1); + + act(() => { + userEvent.tab(); + fireEvent.click(getAllByRole('button').at(0)); + }); + expect(onClickOutside).toBeCalledTimes(1); + }); + + it('should not close onClickOutside if default was prevented or target is inside container', () => { + const onClickOutside = jest.fn(); + const { getByTestId } = render( +
+ +
+ + + +
+ ); + + act(() => { + const evt = createEvent.mouseDown(getByTestId('focus-trap'), { target: getByTestId('inner') }); + fireEvent(getByTestId('inner'), evt); + }); + + expect(onClickOutside).toBeCalledTimes(0); + + act(() => { + const evt = createEvent.mouseDown(getByTestId('outer'), {}); + evt.preventDefault(); + fireEvent(getByTestId('outer'), evt); + }); + + expect(onClickOutside).toBeCalledTimes(0); + }); + + it('should ignore non-focussable elements', () => { + render( + +
test 1
+
+ ); + + act(() => { + userEvent.tab(); + userEvent.tab(); + }); + expect(document.body).toHaveFocus(); + }); + + it('should do nothing when disabled', () => { + const { getAllByRole, getByTestId } = render( +
+ + + +
+ ); + expect(getAllByRole('button').at(0)).not.toHaveFocus(); + + act(() => { + userEvent.tab(); + userEvent.tab(); + userEvent.keyboard('[Escape]'); + userEvent.click(getByTestId('outer')); + }); + expect(document.body).toHaveFocus(); + }); + + it('should be nestable', () => { + const Comp = () => { + const [open1, setOpen1] = React.useState(false); + const [open2, setOpen2] = React.useState(false); + + return ( +
+ + + {open1 && ( + { + setOpen1(false); + }} + > + + {open2 && ( + { + setOpen2(false); + }} + > + + + )} + + )} +
+ ); + }; + + const { getByText } = render(); + + expect(getByText('test 3')).toBeInTheDocument(); + act(() => { + userEvent.tab(); + userEvent.keyboard('[Enter]'); + }); + expect(getByText('test 1')).toBeInTheDocument(); + + act(() => { + expect(getByText('test 1')).toHaveFocus(); + userEvent.keyboard('[Enter]'); + }); + + expect(getByText('test 2')).toBeInTheDocument(); + expect(getByText('test 2')).toHaveFocus(); + + act(() => { + userEvent.keyboard('[Escape]'); + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(getByText('test 1')).toHaveFocus(); + + act(() => { + userEvent.keyboard('[Escape]'); + }); + }); +}); diff --git a/src/hooks/index.js b/src/hooks/index.js index 03e1ec0b4..e68365ce8 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1 +1,2 @@ export { default as useArrowFocus } from './useArrowFocus'; +export { default as useClickOutside } from './useClickOutside'; diff --git a/src/hooks/useArrowFocus.js b/src/hooks/useArrowFocus.js index 63445bbca..c54b291cc 100644 --- a/src/hooks/useArrowFocus.js +++ b/src/hooks/useArrowFocus.js @@ -1,4 +1,5 @@ import React from 'react'; +import { isElementVisible } from '../utils/focus'; import { invariant } from '../utils'; const VALID_KEYS = { @@ -37,7 +38,7 @@ const VALID_KEYS = { * @param {boolean} [options.loop] when true, navigating past the end of the list goes back to the beginning, and vice-versa * @param {'vertical'|'horizontal'} [options.orientation] determines the arrow keys used based on the direction of the list */ -const useArrowFocus = ({ ref, selector, onFocus, loop = true, orientation = 'vertical' }) => { +const useArrowFocus = ({ ref, selector, onFocus, loop = true, disabled: isDisabled, orientation = 'vertical' }) => { invariant(selector, 'useArrowFocus requires a DOM selector to be passed to querySelectorAll'); const onFocusRef = React.useRef(onFocus); @@ -48,22 +49,23 @@ const useArrowFocus = ({ ref, selector, onFocus, loop = true, orientation = 'ver const getDOMList = React.useCallback(() => Array.from(ref.current?.querySelectorAll(selector) ?? 0), [ref, selector]); - const getIsDisabled = ({ disabled, ariaDisabled } = {}) => { - if (disabled || ariaDisabled === 'true') return true; - return false; + const getIsDisabledOrHidden = (el = {}) => { + const { disabled, ariaDisabled } = el; + return disabled || ariaDisabled === 'true' || !isElementVisible(el); }; const focusEl = (n = 0) => { const DOMList = getDOMList(); if (DOMList.length === 0 || !DOMList[n]) return; const nextEl = DOMList[n]; - if (!nextEl || getIsDisabled(nextEl)) return; + if (!nextEl || getIsDisabledOrHidden(nextEl)) return; nextEl.focus(); onFocusRef.current?.(nextEl); }; React.useEffect(() => { + if (isDisabled) return; const focusNext = (isForward) => { const DOMList = getDOMList(); if (DOMList.length === 0) return; @@ -81,7 +83,7 @@ const useArrowFocus = ({ ref, selector, onFocus, loop = true, orientation = 'ver } } - if (!getIsDisabled(DOMList[i])) { + if (!getIsDisabledOrHidden(DOMList[i])) { break; } @@ -91,24 +93,26 @@ const useArrowFocus = ({ ref, selector, onFocus, loop = true, orientation = 'ver } const nextEl = DOMList[i]; - if (nextEl && !getIsDisabled(nextEl)) { + if (nextEl && !getIsDisabledOrHidden(nextEl)) { nextEl.focus(); onFocusRef.current?.(nextEl); } }; const handleKeyDown = (event) => { - if (!ref.current) return; + if (!ref?.current) return; if (!ref.current.contains(document.activeElement)) return; if (!VALID_KEYS[orientation].includes(event.key)) return; - event.preventDefault(); const isForward = ['ArrowDown', 'ArrowRight'].includes(event.key); + + if (event.defaultPrevented) return; + event.preventDefault(); focusNext(isForward); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [getDOMList, loop, orientation, ref]); + }, [isDisabled, getDOMList, loop, orientation, ref]); return { focusEl }; }; diff --git a/src/hooks/useArrowFocus.spec.js b/src/hooks/useArrowFocus.spec.js index cf52e8b32..e70c9d4b2 100644 --- a/src/hooks/useArrowFocus.spec.js +++ b/src/hooks/useArrowFocus.spec.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, cleanup, fireEvent } from '@testing-library/react'; +import { render, cleanup, fireEvent, createEvent, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import useArrowFocus from './useArrowFocus'; @@ -7,12 +7,13 @@ afterEach(cleanup); describe('useArrowFocus()', () => { let props; - const Component = ({ onFocus, refMock, selector = 'li', children }) => { + const Component = ({ onFocus, refMock, disabled, selector = 'li', children }) => { const ref = React.useRef(); useArrowFocus({ ref: refMock ? refMock : ref, onFocus, selector, + disabled, }); return
    {children}
; }; @@ -48,6 +49,39 @@ describe('useArrowFocus()', () => { expect(getByText('2')).toHaveFocus(); }); + it('should handle prevent default', () => { + const { getByRole } = render( + +
  • 1
  • +
  • 2
  • +
    + ); + + const ev = createEvent.keyDown(getByRole('list'), { key: 'ArrowUp' }); + + act(() => { + userEvent.tab(); + ev.preventDefault(); + fireEvent(getByRole('list'), ev); + }); + expect(props.onFocus).toHaveBeenCalledTimes(0); + }); + + it('should be disabled', () => { + render( + +
  • 1
  • +
  • 2
  • +
    + ); + + act(() => { + userEvent.tab(); + userEvent.keyboard('[ArrowDown]'); + }); + expect(props.onFocus).toHaveBeenCalledTimes(0); + }); + it('should handle no valid children, and ignore other elements', () => { const refMock = { current: null }; const { getByText } = render( diff --git a/src/hooks/useClickOutside.js b/src/hooks/useClickOutside.js new file mode 100644 index 000000000..77680700f --- /dev/null +++ b/src/hooks/useClickOutside.js @@ -0,0 +1,24 @@ +import React from 'react'; + +const useClickOutside = (ref, handler) => { + const savedCallback = React.useRef(handler); + + React.useEffect(() => { + savedCallback.current = handler; + }, [handler]); + + React.useEffect(() => { + const listener = (event) => { + if (!ref.current || ref.current.contains(event.target)) return; + savedCallback.current(event); + }; + + document.addEventListener('mousedown', listener); + + return () => { + document.removeEventListener('mousedown', listener); + }; + }, [ref]); +}; + +export default useClickOutside; diff --git a/src/utils/focus/index.js b/src/utils/focus/index.js new file mode 100644 index 000000000..506cf1f81 --- /dev/null +++ b/src/utils/focus/index.js @@ -0,0 +1,96 @@ +// Adapted from: https://github.com/adobe/react-spectrum +// Licensed under the Apache License, Version 2.0 + +const focusableElements = [ + 'input:not([disabled]):not([type=hidden])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'a[href]', + 'area[href]', + 'summary', + 'iframe', + 'object', + 'embed', + 'audio[controls]', + 'video[controls]', + '[contenteditable]', +]; + +const FOCUSABLE_ELEMENT_SELECTOR = + focusableElements.join(':not([hidden]),') + ',[tabindex]:not([disabled]):not([hidden])'; + +focusableElements.push('[tabindex]:not([tabindex="-1"]):not([disabled])'); +const TABBABLE_ELEMENT_SELECTOR = focusableElements.join(':not([hidden]):not([tabindex="-1"]),'); + +function isStyleVisible(element) { + if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { + return false; + } + const { display, visibility } = element.style; + + let isVisible = display !== 'none' && visibility !== 'hidden' && visibility !== 'collapse'; + + if (isVisible) { + const { getComputedStyle } = element.ownerDocument.defaultView; + const { display: computedDisplay, visibility: computedVisibility } = getComputedStyle(element); + + isVisible = computedDisplay !== 'none' && computedVisibility !== 'hidden' && computedVisibility !== 'collapse'; + } + + return isVisible; +} + +export function isElementVisible(element) { + return ( + element && + element.nodeName !== '#comment' && + isStyleVisible(element) && + !element.hasAttribute('hidden') && + (!element.parentElement || isElementVisible(element.parentElement)) + ); +} + +/** + * Create a [TreeWalker]{@link https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker} + * that matches all focusable/tabbable elements. + * @param {Node} root - root node + * @param {{tabbable: boolean, from: Node, accept: Function}} opts - options + */ +export function getFocusableTreeWalker(root, opts = {}) { + const selector = opts.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { + // Skip nodes inside the starting node. + if (opts.from?.contains(node)) { + return NodeFilter.FILTER_REJECT; + } + + if (node.matches(selector) && isElementVisible(node) && (!opts.accept || opts.accept(node))) { + return NodeFilter.FILTER_ACCEPT; + } + + return NodeFilter.FILTER_SKIP; + }, + }); + + if (opts.from) { + walker.currentNode = opts.from; + } + + return walker; +} + +/** + * Get the nodes returned from getFocusableTreeWalker + * @param {Node} root - Root node + * @param {{tabbable: boolean, from: Node, accept: Function}} opts - options + * @returns array of focusable nodes wthin `root` + */ +export const getFocusableNodes = (root, opts = {}) => { + const nodes = []; + const walker = getFocusableTreeWalker(root, opts); + while (walker.nextNode()) nodes.push(walker.currentNode); + return nodes; +}; diff --git a/src/utils/focus/index.spec.js b/src/utils/focus/index.spec.js new file mode 100644 index 000000000..256983dff --- /dev/null +++ b/src/utils/focus/index.spec.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { getFocusableNodes, isElementVisible } from './'; + +afterEach(cleanup); + +describe('utils', () => { + describe('isElementVisible()', () => { + afterEach(cleanup); + it('should work', () => { + expect(isElementVisible(document.body)).toEqual(true); + }); + it('should work with invalid element', () => { + expect(isElementVisible({})).toEqual(false); + }); + }); + + describe('getFocusableNodes()', () => { + afterEach(cleanup); + + const Comp = () => ( +
    + + +
    + +
    +
    + ); + it('should work', () => { + render(); + const nodes = getFocusableNodes(document.body); + expect(nodes).toHaveLength(3); + }); + + it('should start from opts.from', () => { + const { getByTestId } = render(); + const nodes = getFocusableNodes(document.body, { from: getByTestId('b1') }); + expect(nodes).toHaveLength(2); + expect(nodes[0]).toHaveAccessibleName('B2'); + }); + + it('should reject opts.from when starting on it', () => { + const { getByTestId } = render(); + const nodes = getFocusableNodes(getByTestId('inner-div'), { from: getByTestId('inner-div') }); + expect(nodes).toHaveLength(0); + }); + + it('should work with opts.accept', () => { + const { getByTestId } = render(); + const nodes = getFocusableNodes(document.body, { + accept: (node) => node === getByTestId('b2'), + }); + expect(nodes).toHaveLength(1); + expect(nodes[0]).toHaveAccessibleName('B2'); + }); + }); +}); diff --git a/src/utils.js b/src/utils/index.js similarity index 100% rename from src/utils.js rename to src/utils/index.js diff --git a/www/containers/props.json b/www/containers/props.json index 2300fffcc..7d97e0d0c 100644 --- a/www/containers/props.json +++ b/www/containers/props.json @@ -120,6 +120,13 @@ "required": true, "description": "" }, + "onEscapeClose": { + "type": { + "name": "func" + }, + "required": false, + "description": "@param event\ncalled before `onClose` is called, when pressing escape.\n\ncan be prevented with `event.preventDefault()`" + }, "children": { "type": { "name": "node" @@ -160,6 +167,13 @@ "computed": false } }, + "disableFocusTrap": { + "type": { + "name": "bool" + }, + "required": false, + "description": "" + }, "visuallyHidden": { "type": { "name": "bool" @@ -1702,6 +1716,79 @@ } } ], + "src/components/DismissibleFocusTrap/index.jsx": [ + { + "description": "", + "displayName": "DismissibleFocusTrap", + "methods": [], + "props": { + "loop": { + "type": { + "name": "bool" + }, + "required": false, + "description": "loops the tab sequence", + "defaultValue": { + "value": "true", + "computed": false + } + }, + "focusOnMount": { + "type": { + "name": "bool" + }, + "required": false, + "description": "focus the first focussable element on mount", + "defaultValue": { + "value": "true", + "computed": false + } + }, + "disabled": { + "type": { + "name": "bool" + }, + "required": false, + "description": "disable all behaviour" + }, + "onEscape": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "onClickOutside": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "onTabExit": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "onShiftTabExit": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + } + } + } + ], "src/components/Empty/index.jsx": [ { "description": "", diff --git a/www/examples/ActionPanel.mdx b/www/examples/ActionPanel.mdx index af0a5db54..44691164d 100644 --- a/www/examples/ActionPanel.mdx +++ b/www/examples/ActionPanel.mdx @@ -70,82 +70,60 @@ In rare cases, a component in a modal triggers its own modal. Use `visuallyHidden` to hide the parent modal when opening the child. ```jsx live=true -class Example extends React.PureComponent { - constructor() { - super(); - this.state = { - showActionPanel: false, - showActionPanel2: false, - }; - this.toggleActionPanel = this.toggleActionPanel.bind(this); - this.toggleActionPanel2 = this.toggleActionPanel2.bind(this); - } +const Example = () => { + const [showActionPanel1, setShowActionPanel1] = React.useState(); + const [showActionPanel2, setShowActionPanel2] = React.useState(); - toggleActionPanel() { - this.setState({ showActionPanel: !this.state.showActionPanel }); - } - toggleActionPanel2() { - this.setState({ showActionPanel2: !this.state.showActionPanel2 }); - } + return ( + + - render() { - return ( - - - {this.state.showActionPanel && ( - Save} - isModal - children={ + {showActionPanel1 && ( + setShowActionPanel1(false)} + visuallyHidden={showActionPanel2} + actionButton={} + isModal + children={ +
    + Native mammals include the dingoes or wild dogs, numbats, quolls, and Tasmanian devils. Dingoes are the + largest carnivorous mammals that populate the wilds of mainland Australia. But the smaller numbats and + Tasmanian devils, which are house cat-like size can be seen only in wildlife parks. You can also spot them + in the wilds of Tasmania. +
    +
    - Native mammals include the dingoes or wild dogs, numbats, quolls, and Tasmanian devils. Dingoes are the - largest carnivorous mammals that populate the wilds of mainland Australia. But the smaller numbats and - Tasmanian devils, which are house cat-like size can be seen only in wildlife parks. You can also spot - them in the wilds of Tasmania. -
    -
    -
    - -
    - {this.state.showActionPanel2 && ( - { - this.toggleActionPanel2(); - this.toggleActionPanel(); - }} - > - Done - - } - isModal - children={ -
    - Native mammals include the dingoes or wild dogs, numbats, quolls, and Tasmanian devils. Dingoes - are the largest carnivorous mammals that populate the wilds of mainland Australia. But the - smaller numbats and Tasmanian devils, which are house cat-like size can be seen only in wildlife - parks. You can also spot them in the wilds of Tasmania. -
    - } - /> - )} +
    - } - /> - )} - - ); - } -} + {showActionPanel2 && ( + setShowActionPanel2(false)} + cancelText="Back" + actionButton={ + + } + isModal + children={
    aaa
    } + /> + )} +
    + } + /> + )} +
    + ); +}; render(Example); ```