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(
+
+ Button
+
+
+ );
+ 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(
+
+ Button
+
+
+ );
+
+ 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(
+
+ Button
+
+
+ );
+
+ 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 (
+
+ {
+ setShowNestedActionPanel(true);
+ }}
+ />
+ {showNestedActionPanel && (
+ setShowNestedActionPanel(false)} />}
+ >
+ ...
+
+ )}
+
+ );
+ };
+ const { getByTestId, getAllByTestId } = render( );
+
+ act(() => {
+ userEvent.tab();
+ expect(getByTestId('show-nested')).toHaveFocus();
+ userEvent.keyboard('[Enter]');
+ });
+
+ expect(getAllByTestId('action-panel-modal-wrapper')[0]).toHaveClass('visually-hidden');
+ expect(getAllByTestId('action-panel-wrapper')).toHaveLength(2);
+ expect(getByTestId('nested-cancel')).toHaveFocus();
+
+ act(() => {
+ userEvent.keyboard('[Enter]');
+ });
+
+ act(() => jest.runAllTimers());
+
+ expect(getByTestId('show-nested')).toHaveFocus();
+ });
+
it('should render a user specified text on the cancel button', () => {
let wrapper;
act(() => {
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`] = `
-
-
- Are you sure?
-
+
+ Confirm
+
+
+
+
+
`;
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..63c882000
--- /dev/null
+++ b/src/components/DismissibleFocusTrap/index.jsx
@@ -0,0 +1,160 @@
+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;
+ const contentEl = contentRef.current;
+ // 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;
+ if (!focusLayers.isHighestLayer(contentEl)) {
+ 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..b1a87acb3
--- /dev/null
+++ b/src/components/DismissibleFocusTrap/index.spec.jsx
@@ -0,0 +1,374 @@
+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(
+
+ test
+
+ );
+
+ // 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(
+
+ test 1
+
+ test 1
+
+
+
+ );
+
+ 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(
+
+ test 1
+
+ );
+ 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(
+
+ test 1
+ test 2
+
+ );
+ 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(
+
+ test 1
+
+ );
+ 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(
+
+ test 1
+ test 2
+
+ );
+
+ 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(
+
+ test 1
+ test 2
+
+ );
+
+ 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(
+
+
+ test 1
+ test 2
+
+
+ );
+
+ 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(
+
+
+
+ test 1
+ test 2
+
+
+ );
+
+ 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 focus previous focussed el when only when unmounting the highest layer', () => {
+ const Comp = () => {
+ const [modalOpen, setModalOpen] = React.useState(false);
+ const [menuOpen, setMenuOpen] = React.useState(false);
+
+ return (
+
+ setMenuOpen(true)}>Open menu
+ {menuOpen && (
+
+ {
+ setMenuOpen(false);
+ setModalOpen(true);
+ }}
+ >
+ Open modal
+
+
+ )}
+
+ {modalOpen && (
+ <>
+ {
+ setModalOpen(false);
+ }}
+ >
+ test
+
+ {
+ setModalOpen(false);
+ }}
+ >
+ test 2
+
+ >
+ )}
+
+ );
+ };
+
+ const { getByText } = render( );
+
+ act(() => {
+ userEvent.tab();
+ });
+
+ expect(getByText('Open menu')).toHaveFocus();
+
+ act(() => {
+ userEvent.keyboard('[Enter]');
+ });
+ act(() => {
+ jest.runAllTimers();
+ });
+
+ expect(getByText('Open modal')).toHaveFocus();
+
+ act(() => {
+ userEvent.keyboard('[Enter]');
+ });
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(getByText('Open menu')).toHaveFocus();
+
+ act(() => {
+ userEvent.keyboard('[Escape]');
+ jest.runAllTimers();
+ });
+ expect(getByText('Open menu')).toHaveFocus();
+ });
+
+ it('should do nothing when disabled', () => {
+ const { getAllByRole, getByTestId } = render(
+
+
+ test 1
+
+
+ );
+ 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 (
+
+ setOpen1(true)}>test 3
+
+ {open1 && (
+ {
+ setOpen1(false);
+ }}
+ >
+ setOpen2(true)}>test 1
+ {open2 && (
+ {
+ setOpen2(false);
+ }}
+ >
+ test 2
+
+ )}
+
+ )}
+
+ );
+ };
+
+ 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 ;
};
@@ -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 (
+
+ setShowActionPanel1(true)}>Action Panel as a modal
- render() {
- return (
-
- Action Panel as a modal
- {this.state.showActionPanel && (
- Save}
- isModal
- children={
+ {showActionPanel1 && (
+ setShowActionPanel1(false)}
+ visuallyHidden={showActionPanel2}
+ actionButton={ setShowActionPanel1(false)}>Save }
+ 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.
-
-
-
- Action Panel 2 as a modal
-
- {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.
-
- }
- />
- )}
+ setShowActionPanel2(true)}>Action Panel 2 as a modal
- }
- />
- )}
-
- );
- }
-}
+ {showActionPanel2 && (
+
setShowActionPanel2(false)}
+ cancelText="Back"
+ actionButton={
+ {
+ setShowActionPanel1(false);
+ setShowActionPanel2(false);
+ }}
+ >
+ Done
+
+ }
+ isModal
+ children={aaa
}
+ />
+ )}
+
+ }
+ />
+ )}
+
+ );
+};
render(Example);
```