From e992b11b9a08b06aae2c82bd431ed3abe56a1e37 Mon Sep 17 00:00:00 2001 From: renrizzolo Date: Tue, 20 Dec 2022 14:25:13 +1100 Subject: [PATCH] feat: focus utils --- .gitignore | 2 + .prettierignore | 1 + scripts/generate-types/generateTypes.js | 2 +- src/components/ActionPanel/index.d.ts | 7 + src/components/ActionPanel/index.jsx | 75 +++-- src/components/ActionPanel/index.spec.jsx | 110 ++++++ .../__snapshots__/index.spec.jsx.snap | 81 +++-- .../DismissibleFocusTrap/index.d.ts | 25 ++ src/components/DismissibleFocusTrap/index.jsx | 153 +++++++++ .../DismissibleFocusTrap/index.spec.jsx | 315 ++++++++++++++++++ src/hooks/index.js | 1 + src/hooks/useArrowFocus.js | 24 +- src/hooks/useArrowFocus.spec.js | 38 ++- src/hooks/useClickOutside.js | 24 ++ src/lib/focus.js | 96 ++++++ src/lib/focus.spec.js | 58 ++++ www/containers/props.json | 87 +++++ www/examples/ActionPanel.mdx | 37 +- 18 files changed, 1059 insertions(+), 77 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/lib/focus.js create mode 100644 src/lib/focus.spec.js diff --git a/.gitignore b/.gitignore index cd0ce9aa1..0cf37b2a4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,6 @@ dist lib +!src/lib + es diff --git a/.prettierignore b/.prettierignore index 9181b3107..afd0a0b44 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ package-lock.json dist es lib +!src/lib 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 992f342c8..4de80816e 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; closeIcon?: React.ReactNode; isModal?: boolean; + disableFocusTrap?: boolean; cancelText?: string; /** * Hides the modal with css, but keeps it mounted. diff --git a/src/components/ActionPanel/index.jsx b/src/components/ActionPanel/index.jsx index ab9056209..aa06ca147 100644 --- a/src/components/ActionPanel/index.jsx +++ b/src/components/ActionPanel/index.jsx @@ -2,6 +2,7 @@ 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 '../../lib/utils'; import Button from '../Button'; import './styles.css'; @@ -12,12 +13,14 @@ const ActionPanel = React.forwardRef((props, ref) => { className, size, onClose, + onEscapeClose, children, visuallyHidden, actionButton, isModal, closeIcon, cancelText, + disableFocusTrap, dts, } = props; @@ -33,6 +36,12 @@ const ActionPanel = React.forwardRef((props, ref) => { }; }, [isModal, visuallyHidden]); + const onEscapeHandler = (event) => { + onEscapeClose?.(event); + if (event.defaultPrevented) return; + onClose(); + }; + const actionPanel = (
{ })} >
-
-
- {title} +
+
+ {title} +
+ + {actionButton ? ( + + ) : ( +
+
+ {children}
- - {actionButton ? ( - - ) : ( -
-
- {children} -
+
@@ -91,10 +108,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, closeIcon: PropTypes.node, isModal: PropTypes.bool, + disableFocusTrap: PropTypes.bool, cancelText: PropTypes.string, /** * Hides the modal with css, but keeps it mounted. diff --git a/src/components/ActionPanel/index.spec.jsx b/src/components/ActionPanel/index.spec.jsx index e5c6bb564..895f74b77 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,6 +69,107 @@ 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 not close when onClickOutsideClose prevents default', () => { + const onClickOutsideClose = (e) => e.preventDefault(); + const onClose = jest.fn(); + const { getByTestId } = render( +
+ + + + +
+ ); + + act(() => { + userEvent.click(getByTestId('outer')); + }); + expect(onClose).not.toBeCalled(); + }); + it('should hide the modal with the visuallyHidden prop', () => { const { getByTestId } = render(); diff --git a/src/components/ConfirmModal/__snapshots__/index.spec.jsx.snap b/src/components/ConfirmModal/__snapshots__/index.spec.jsx.snap index 27c25b444..41064b632 100644 --- a/src/components/ConfirmModal/__snapshots__/index.spec.jsx.snap +++ b/src/components/ConfirmModal/__snapshots__/index.spec.jsx.snap @@ -2,53 +2,60 @@ exports[` should show modal when \`show\` is true 1`] = ` `; 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..0f5069d3c --- /dev/null +++ b/src/components/DismissibleFocusTrap/index.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { getFocusableNodes } from '../../lib/focus'; +import { useClickOutside } from '../../hooks'; + +const layers = new Set(); + +const DismissibleFocusTrap = ({ + loop = true, + focusOnMount = true, + disabled, + onEscape, + onClickOutside, + onTabExit, + onShiftTabExit, + children, + ...rest +}) => { + const contentRef = React.useRef(); + const clickedOutsideRef = React.useRef(); + + const getIsHighestLayer = React.useCallback(() => { + const layersArr = Array.from(layers); + const isHighestLayer = layersArr.indexOf(contentRef.current) === Math.max(layers.size - 1, 0); + return { isHighestLayer, size: layers.size }; + }, []); + + const clickOutsideHandler = React.useCallback( + (event) => { + if (disabled) return; + if (event.defaultPrevented) return; + + if (onClickOutside) { + const { isHighestLayer, size } = getIsHighestLayer(); + if (isHighestLayer) { + if (size === 1) { + // don't steal focus if closing via clicking outside + clickedOutsideRef.current = true; + } + onClickOutside?.(event); + event.stopPropagation(); + event.preventDefault(); + } + } + }, + [onClickOutside, getIsHighestLayer, disabled] + ); + + useClickOutside(contentRef, clickOutsideHandler); + + React.useEffect(() => { + if (disabled || !focusOnMount) return; + const previousFocusEl = document.activeElement; + const nodes = getFocusableNodes(contentRef?.current, { tabbable: true }); + + // timeouts are a hack for cases where the state that causes this component to mount/unmount + // also toggles the visibility of the previousFocusEl/parent + window.setTimeout(() => { + nodes[0]?.focus({ preventScroll: true }); + }); + + return () => { + if (clickedOutsideRef.current) return; + window.setTimeout(() => { + previousFocusEl?.focus(); + }); + }; + }, [disabled, focusOnMount]); + + React.useEffect(() => { + if (disabled) return; + const node = contentRef.current; + node && layers.add(node); + + return () => { + node && layers.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 } = getIsHighestLayer(); + if (isHighestLayer) { + onEscape?.(event); + event.stopPropagation(); + event.preventDefault(); + } + } + }; + document.addEventListener('keydown', onEscapeKeyDown); + return () => { + document.removeEventListener('keydown', onEscapeKeyDown); + }; + }, [getIsHighestLayer, 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..cba577657 --- /dev/null +++ b/src/components/DismissibleFocusTrap/index.spec.jsx @@ -0,0 +1,315 @@ +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(() => { + jest.runAllTimers(); + userEvent.tab(); + jest.runAllTimers(); + }); + expect(getAllByRole('combobox').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab(); + jest.runAllTimers(); + }); + expect(getAllByRole('textbox').at(0)).toHaveFocus(); + + act(() => { + userEvent.tab(); + jest.runAllTimers(); + }); + 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(); + const { getAllByRole } = render( + + + + + ); + expect(getAllByRole('button').at(0)).not.toHaveFocus(); + + act(() => { + userEvent.tab(); + 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 { getAllByRole, getByTestId } = render( + + + + + ); + expect(getAllByRole('button').at(0)).not.toHaveFocus(); + + 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( +
+ + + + +
+ ); + expect(getAllByRole('button').at(0)).not.toHaveFocus(); + + 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 { getAllByRole, getByTestId } = render( +
+ +
+ + + +
+ ); + expect(getAllByRole('button').at(0)).not.toHaveFocus(); + + 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(() => { + jest.runAllTimers(); + }); + + act(() => { + expect(getByText('test 1')).toHaveFocus(); + userEvent.keyboard('[Enter]'); + }); + + act(() => { + jest.runAllTimers(); + }); + + 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]'); + jest.runAllTimers(); + }); + }); +}); 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 b94fafb47..c3c2ee3c1 100644 --- a/src/hooks/useArrowFocus.js +++ b/src/hooks/useArrowFocus.js @@ -1,4 +1,5 @@ import React from 'react'; +import { isElementVisible } from '../lib/focus'; import { invariant } from '../lib/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/lib/focus.js b/src/lib/focus.js new file mode 100644 index 000000000..efd64b814 --- /dev/null +++ b/src/lib/focus.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; + } + let { display, visibility } = element.style; + + let isVisible = display !== 'none' && visibility !== 'hidden' && visibility !== 'collapse'; + + if (isVisible) { + const { getComputedStyle } = element.ownerDocument.defaultView; + let { 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 = {}) { + let selector = opts.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR; + + let 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 = {}) => { + let nodes = []; + const walker = getFocusableTreeWalker(root, opts); + while (walker.nextNode()) nodes.push(walker.currentNode); + return nodes; +}; diff --git a/src/lib/focus.spec.js b/src/lib/focus.spec.js new file mode 100644 index 000000000..7db004d33 --- /dev/null +++ b/src/lib/focus.spec.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import { getFocusableNodes, isElementVisible } from './focus'; + +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/www/containers/props.json b/www/containers/props.json index ebb27b179..624c7f8b9 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": "" + }, "cancelText": { "type": { "name": "string" @@ -1713,6 +1727,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..98d2a2704 100644 --- a/www/examples/ActionPanel.mdx +++ b/www/examples/ActionPanel.mdx @@ -91,13 +91,19 @@ class Example extends React.PureComponent { render() { return ( - + Action Panel as a modal} + triggers="click" + isMenu + > + + {this.state.showActionPanel && ( console.log('this')} actionButton={} isModal children={ @@ -114,6 +120,7 @@ class Example extends React.PureComponent { {this.state.showActionPanel2 && ( console.log('this')} size="medium" onClose={this.toggleActionPanel2} cancelText="Back" @@ -130,10 +137,36 @@ class Example extends React.PureComponent { isModal children={
    + 1} isMenu> + + + {}} + placeholderText="Date e.g. 03 Sep 2016" + disableInlineEditing={false} + /> 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. +