diff --git a/.yarn/cache/wicg-inert-npm-3.1.1-27dfbc6ece-d4553ea762.zip b/.yarn/cache/wicg-inert-npm-3.1.1-27dfbc6ece-d4553ea762.zip new file mode 100644 index 000000000000..236a00e3042a Binary files /dev/null and b/.yarn/cache/wicg-inert-npm-3.1.1-27dfbc6ece-d4553ea762.zip differ diff --git a/packages/react/package.json b/packages/react/package.json index 609b165e1af3..cac689263484 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -60,6 +60,7 @@ "lodash.throttle": "^4.1.1", "react-is": "^16.8.6", "use-resize-observer": "^6.0.0", + "wicg-inert": "^3.1.1", "window-or-global": "^1.0.1" }, "devDependencies": { diff --git a/packages/react/src/components/Dialog/Dialog-story.js b/packages/react/src/components/Dialog/Dialog-story.js new file mode 100644 index 000000000000..00461a685795 --- /dev/null +++ b/packages/react/src/components/Dialog/Dialog-story.js @@ -0,0 +1,152 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { FocusScope } from '../FocusScope'; +import { Dialog } from '../Dialog'; +import { useId } from '../../internal/useId'; +import { Portal } from '../Portal'; + +export default { + title: 'Experimental/unstable_Dialog', + includeStories: [], +}; + +export const Default = () => { + function DemoComponent() { + const [open, setOpen] = React.useState(false); + const ref = React.useRef(null); + + return ( +
+ + {open ? ( + +
+

+ Elit hic at labore culpa itaque fugiat. Consequuntur iure autem + autem officiis dolores facilis nulla earum! Neque quia nemo + sequi assumenda ratione officia Voluptate beatae eligendi + placeat nemo laborum, ratione. +

+ + +
+
+ ) : null} +
+ ); + } + return ( + <> + + + + ); +}; + +export const DialogExample = () => { + function Example() { + const [open, setOpen] = React.useState(false); + const id = useId(); + + return ( +
+
+ +
+ + {open ? ( + + + { + setOpen(false); + }} + style={{ + position: 'relative', + zIndex: 9999, + padding: '1rem', + background: 'white', + }}> +
+ Hello +
+
+ +
+ +
+
+ ) : null} + +
+ +
+
+ ); + } + + return ; +}; + +const FullPage = React.forwardRef(function FullPage(props, ref) { + return ( +
+ ); +}); diff --git a/packages/react/src/components/Dialog/index.js b/packages/react/src/components/Dialog/index.js new file mode 100644 index 000000000000..3569f5ff4a77 --- /dev/null +++ b/packages/react/src/components/Dialog/index.js @@ -0,0 +1,153 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import 'wicg-inert'; +import PropTypes from 'prop-types'; +import React, { useEffect, useRef } from 'react'; +import { FocusScope } from '../FocusScope'; +import { useMergedRefs } from '../../internal/useMergedRefs'; +import { useSavedCallback } from '../../internal/useSavedCallback'; +import { match, keys } from '../../internal/keyboard'; + +/** + * @see https://www.tpgi.com/the-current-state-of-modal-dialog-accessibility/ + */ +const Dialog = React.forwardRef(function Dialog(props, forwardRef) { + const { 'aria-labelledby': labelledBy, children, onDismiss, ...rest } = props; + const dialogRef = useRef(null); + const ref = useMergedRefs([dialogRef, forwardRef]); + const savedOnDismiss = useSavedCallback(onDismiss); + + function onKeyDown(event) { + if (match(event, keys.Escape)) { + event.stopPropagation(); + savedOnDismiss(); + } + } + + useEffect(() => { + const changes = hide(document.body, dialogRef.current); + return () => { + show(changes); + }; + }, []); + + return ( + + {children} + + ); +}); + +Dialog.propTypes = { + /** + * Provide the associated element that labels the Dialog + */ + 'aria-labelledby': PropTypes.string.isRequired, + + /** + * Provide children to be rendered inside of the Dialog + */ + children: PropTypes.node, + + /** + * Provide a handler that is called when the Dialog is requesting to be closed + */ + onDismiss: PropTypes.func.isRequired, +}; + +if (__DEV__) { + Dialog.displayName = 'Dialog'; +} + +function hide(root, dialog) { + const changes = []; + const queue = Array.from(root.childNodes); + + while (queue.length !== 0) { + const node = queue.shift(); + + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + // If a node is the dialog, do nothing + if (node === dialog) { + continue; + } + + // If a tree contains our dialog, traverse its children + if (node.contains(dialog)) { + queue.push(...Array.from(node.childNodes)); + continue; + } + + // If a node is a bumper, do nothing + if ( + node.hasAttribute('data-carbon-focus-scope') && + (dialog.previousSibling === node || dialog.nextSibling === node) + ) { + continue; + } + + if (node.getAttribute('aria-hidden') === 'true') { + continue; + } + + if (node.hasAttribute('inert')) { + continue; + } + + if (node.getAttribute('aria-hidden') === 'false') { + node.setAttribute('aria-hidden', 'true'); + node.setAttribute('inert', ''); + changes.push({ + node, + attributes: { + 'aria-hidden': 'false', + }, + }); + continue; + } + + // Otherwise, set it to inert and set aria-hidden to true + node.setAttribute('aria-hidden', 'true'); + node.setAttribute('inert', ''); + + changes.push({ + node, + }); + } + + return changes; +} + +function show(changes) { + changes.forEach(({ node, attributes }) => { + node.removeAttribute('inert'); + // This mutation needs to be asynchronous to allow the polyfill time to + // observe the change and allow mutations to occur + // https://github.com/WICG/inert#performance-and-gotchas + setTimeout(() => { + if (attributes && attributes['aria-hidden']) { + node.setAttribute('aria-hidden', attributes['aria-hidden']); + } else { + node.removeAttribute('aria-hidden'); + } + }, 0); + }); +} + +export { Dialog }; diff --git a/packages/react/src/components/FocusScope/index.js b/packages/react/src/components/FocusScope/index.js new file mode 100644 index 000000000000..038c431b7ab2 --- /dev/null +++ b/packages/react/src/components/FocusScope/index.js @@ -0,0 +1,100 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { useMergedRefs } from '../../internal/useMergedRefs'; +import { useAutoFocus } from './useAutoFocus'; +import { useFocusScope } from './useFocusScope'; +import { useRestoreFocus } from './useRestoreFocus'; + +const FocusScope = React.forwardRef(function FocusScope(props, forwardRef) { + const { + as: BaseComponent = 'div', + children, + initialFocusRef, + ...rest + } = props; + const containerRef = React.useRef(null); + const focusScope = useFocusScope(containerRef); + const ref = useMergedRefs([forwardRef, containerRef]); + + useRestoreFocus(containerRef); + useAutoFocus(() => { + if (initialFocusRef) { + return initialFocusRef; + } + return focusScope.current.getFirstDescendant(); + }); + + return ( + <> + { + focusScope.current.focusLastDescendant(); + }} + /> + + {children} + + { + focusScope.current.focusFirstDescendant(); + }} + /> + + ); +}); + +if (__DEV__) { + FocusScope.displayName = 'FocusScope'; +} + +FocusScope.propTypes = { + /** + * Provide a custom element type for the containing element + */ + as: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.string, + PropTypes.elementType, + ]), + + /** + * Provide the children to be rendered inside of the `FocusScope` + */ + children: PropTypes.node, + + /** + * Provide a `ref` that is used to place focus when the `FocusScope` is + * initially opened + */ + initialFocusRef: PropTypes.shape({ + current: PropTypes.any, + }), +}; + +const bumperStyle = { + outline: 'none', + opacity: '0', + position: 'fixed', + pointerEvents: 'none', +}; + +function FocusScopeBumper(props) { + return ( + + ); +} + +export { FocusScope }; diff --git a/packages/react/src/components/FocusScope/useAutoFocus.js b/packages/react/src/components/FocusScope/useAutoFocus.js new file mode 100644 index 000000000000..64c3cc505b43 --- /dev/null +++ b/packages/react/src/components/FocusScope/useAutoFocus.js @@ -0,0 +1,23 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect, useRef } from 'react'; +import { focus } from '../../internal/focus'; + +export function useAutoFocus(getElementOrRef) { + const callbackRef = useRef(getElementOrRef); + + useEffect(() => { + if (callbackRef.current) { + const elementOrRef = callbackRef.current(); + const element = elementOrRef.current || elementOrRef; + if (element) { + focus(element); + } + } + }, []); +} diff --git a/packages/react/src/components/FocusScope/useFocusScope.js b/packages/react/src/components/FocusScope/useFocusScope.js new file mode 100644 index 000000000000..6b7f25ce9007 --- /dev/null +++ b/packages/react/src/components/FocusScope/useFocusScope.js @@ -0,0 +1,55 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useRef } from 'react'; +import { focus } from '../../internal/focus'; + +export function useFocusScope(containerRef) { + const focusScope = useRef(null); + + if (focusScope.current === null) { + focusScope.current = createFocusScope(containerRef); + } + + return focusScope; +} + +function createFocusWalker(container) { + return document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { + acceptNode(node) { + if (node.tabIndex >= 0 && !node.disabled) { + return NodeFilter.FILTER_ACCEPT; + } + return NodeFilter.FILTER_SKIP; + }, + }); +} + +function createFocusScope(root) { + const focusScope = { + getFirstDescendant() { + const walker = createFocusWalker(root.current); + return walker.firstChild(); + }, + focusFirstDescendant() { + const walker = createFocusWalker(root.current); + const firstChild = walker.firstChild(); + if (firstChild) { + focus(firstChild); + } + }, + focusLastDescendant() { + const walker = createFocusWalker(root.current); + const lastChild = walker.lastChild(); + if (lastChild) { + focus(lastChild); + } + }, + }; + + return focusScope; +} diff --git a/packages/react/src/components/FocusScope/useRestoreFocus.js b/packages/react/src/components/FocusScope/useRestoreFocus.js new file mode 100644 index 000000000000..ae41d8b1cd12 --- /dev/null +++ b/packages/react/src/components/FocusScope/useRestoreFocus.js @@ -0,0 +1,49 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useEffect, useRef } from 'react'; +import { focus } from '../../internal/focus'; + +export function useRestoreFocus(container) { + const containsFocus = useRef(false); + + useEffect(() => { + const initialActiveElement = document.activeElement; + + if (container.current && container.current.contains) { + containsFocus.current = container.current.contains( + document.activeElement + ); + } + + function onFocusIn() { + containsFocus.current = true; + } + + function onFocusOut(event) { + if (container.current && container.current.contains) { + containsFocus.current = container.current.contains(event.relatedTarget); + } + } + + const { current: element } = container; + + element.addEventListener('focusin', onFocusIn); + element.addEventListener('focusout', onFocusOut); + + return () => { + element.removeEventListener('focusin', onFocusIn); + element.removeEventListener('focusout', onFocusOut); + + if (containsFocus.current === true) { + setTimeout(() => { + focus(initialActiveElement); + }, 0); + } + }; + }, [container]); +} diff --git a/packages/react/src/components/Portal/__tests__/Portal-test.js b/packages/react/src/components/Portal/__tests__/Portal-test.js new file mode 100644 index 000000000000..b7a35a839cb6 --- /dev/null +++ b/packages/react/src/components/Portal/__tests__/Portal-test.js @@ -0,0 +1,45 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Portal } from '../'; + +describe('Portal', () => { + it('should render its children in the document', () => { + render( + + + + ); + expect(screen.getByTestId('test')).toBeInTheDocument(); + }); + + it('should support rendering in a custom container', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + function TestComponent() { + const ref = React.useRef(null); + + if (ref.current === null) { + ref.current = container; + } + + return ( + + + + ); + } + + render(); + + expect(container).toContainElement(screen.getByTestId('test')); + document.body.removeChild(container); + }); +}); diff --git a/packages/react/src/components/Portal/index.js b/packages/react/src/components/Portal/index.js new file mode 100644 index 000000000000..dff5729c0b91 --- /dev/null +++ b/packages/react/src/components/Portal/index.js @@ -0,0 +1,48 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; + +/** + * Helper component for rendering content within a portal. By default, the + * portal will render into document.body. You can customize this behavior with + * the `container` prop. Any `children` provided to this component will be + * rendered inside of the container. + */ +function Portal({ container, children }) { + const [mountNode, setMountNode] = useState(null); + + useEffect(() => { + setMountNode(container ? container.current : document.body); + }, [container]); + + if (mountNode) { + return ReactDOM.createPortal(children, mountNode); + } + + return null; +} + +Portal.propTypes = { + /** + * Specify the children elements to be rendered inside of the + */ + children: PropTypes.node, + + /** + * Provide a ref for a container node to render the portal + */ + container: PropTypes.oneOfType([ + PropTypes.shape({ + current: PropTypes.any, + }), + ]), +}; + +export { Portal }; diff --git a/packages/react/src/internal/focus/index.js b/packages/react/src/internal/focus/index.js new file mode 100644 index 000000000000..5ee20d3bcde5 --- /dev/null +++ b/packages/react/src/internal/focus/index.js @@ -0,0 +1,13 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +export function focus(elementOrRef) { + const element = elementOrRef.current || elementOrRef; + if (element && element.focus && document.activeElement !== element) { + element.focus(); + } +} diff --git a/packages/react/src/internal/useMergedRefs.js b/packages/react/src/internal/useMergedRefs.js new file mode 100644 index 000000000000..e79721291935 --- /dev/null +++ b/packages/react/src/internal/useMergedRefs.js @@ -0,0 +1,29 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useCallback } from 'react'; + +/** + * Combine multiple refs into a single ref. This use useful when you have two + * refs from both `React.forwardRef` and `useRef` that you would like to add to + * the same node. + * + * @param {Array} refs + * @returns {Function} + */ +export function useMergedRefs(refs) { + return useCallback((node) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref !== null && ref !== undefined) { + ref.current = node; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, refs); +} diff --git a/packages/react/src/internal/useSavedCallback.js b/packages/react/src/internal/useSavedCallback.js new file mode 100644 index 000000000000..9a4c4f757b47 --- /dev/null +++ b/packages/react/src/internal/useSavedCallback.js @@ -0,0 +1,31 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Provide a stable reference for a callback that is passed as a prop to a + * component. This is helpful when you want access to the latest version of a + * callback prop but don't want it to be added to the dependency array of an + * effect. + * + * @param {Function} callback + * @returns {Function} + */ +export function useSavedCallback(callback) { + const savedCallback = useRef(callback); + + useEffect(() => { + savedCallback.current = callback; + }); + + return useCallback(() => { + if (savedCallback.current) { + return savedCallback.current(); + } + }, []); +} diff --git a/yarn.lock b/yarn.lock index 005b57cfac71..bf1b30267852 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10377,6 +10377,7 @@ __metadata: webpack: ^4.41.5 webpack-dev-server: ^3.11.2 whatwg-fetch: ^3.6.2 + wicg-inert: ^3.1.1 window-or-global: ^1.0.1 peerDependencies: carbon-components: ^10.30.0 @@ -34941,6 +34942,13 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"wicg-inert@npm:^3.1.1": + version: 3.1.1 + resolution: "wicg-inert@npm:3.1.1" + checksum: d4553ea762ad5808b2d20990f05695d3272cf5d24ee9b0008d0edb0c69466eb6a24824ed932070553a2dc8b25eee8fcde780e073de89d640882d9da99f874944 + languageName: node + linkType: hard + "wide-align@npm:^1.1.0": version: 1.1.3 resolution: "wide-align@npm:1.1.3"