From 986c2538b4db87b4fa87081444b6da16ee52b5b1 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 | 108 ++++++++ .../__snapshots__/index.spec.jsx.snap | 81 +++--- .../DismissableFocusTrap/index.d.ts | 25 ++ src/components/DismissableFocusTrap/index.jsx | 124 +++++++++ .../DismissableFocusTrap/index.spec.jsx | 244 ++++++++++++++++++ src/hooks/index.js | 1 + src/hooks/useArrowFocus.js | 23 +- 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 +++++++ 17 files changed, 922 insertions(+), 74 deletions(-) create mode 100644 src/components/DismissableFocusTrap/index.d.ts create mode 100644 src/components/DismissableFocusTrap/index.jsx create mode 100644 src/components/DismissableFocusTrap/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..d05dfe256 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 DismissableFocusTrap from '../DismissableFocusTrap'; 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..c7a56b962 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,105 @@ 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.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.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/DismissableFocusTrap/index.d.ts b/src/components/DismissableFocusTrap/index.d.ts new file mode 100644 index 000000000..1aa04453d --- /dev/null +++ b/src/components/DismissableFocusTrap/index.d.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; + +export interface DismissableFocusTrapProps { + /** + * 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 DismissableFocusTrap: React.FC; + +export default DismissableFocusTrap; diff --git a/src/components/DismissableFocusTrap/index.jsx b/src/components/DismissableFocusTrap/index.jsx new file mode 100644 index 000000000..5f5610cc5 --- /dev/null +++ b/src/components/DismissableFocusTrap/index.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { getFocusableNodes } from '../../lib/focus'; +import { useClickOutside } from '../../hooks'; + +const DismissableFocusTrap = ({ + 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) { + // don't steal focus if closing via clicking outside + clickedOutsideRef.current = true; + onClickOutside(event); + } + }, + [onClickOutside, disabled] + ); + + useClickOutside(contentRef, clickOutsideHandler); + + const onEscapeKeyDown = React.useCallback( + (event) => { + if (event.key === 'Escape') { + if (event.defaultPrevented) return; + onEscape?.(event); + } + }, + [onEscape] + ); + + React.useEffect(() => { + if (disabled) return; + document.addEventListener('keydown', onEscapeKeyDown); + return () => { + document.removeEventListener('keydown', onEscapeKeyDown); + }; + }, [onEscapeKeyDown, disabled]); + + 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]); + + 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, loop, onTabExit, onShiftTabExit] + ); + + return ( +
+ {children} +
+ ); +}; + +DismissableFocusTrap.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 DismissableFocusTrap; diff --git a/src/components/DismissableFocusTrap/index.spec.jsx b/src/components/DismissableFocusTrap/index.spec.jsx new file mode 100644 index 000000000..afc77b0f7 --- /dev/null +++ b/src/components/DismissableFocusTrap/index.spec.jsx @@ -0,0 +1,244 @@ +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('shouldwork 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(); + }); +}); 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..7c1becc12 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,8 +49,9 @@ 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; + const getIsDisabledOrHidden = (el = {}) => { + const { disabled, ariaDisabled } = el; + if (disabled || ariaDisabled === 'true' || !isElementVisible(el)) return true; return false; }; @@ -57,13 +59,14 @@ const useArrowFocus = ({ ref, selector, onFocus, loop = true, orientation = 'ver 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 +84,7 @@ const useArrowFocus = ({ ref, selector, onFocus, loop = true, orientation = 'ver } } - if (!getIsDisabled(DOMList[i])) { + if (!getIsDisabledOrHidden(DOMList[i])) { break; } @@ -91,24 +94,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..dcf46ff0a 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/DismissableFocusTrap/index.jsx": [ + { + "description": "", + "displayName": "DismissableFocusTrap", + "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": "",