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 (
+
+
{
+ setOpen(true);
+ }}>
+ Open
+
+ {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.
+
+
+
{
+ setOpen(false);
+ }}>
+ Close
+
+
+
+ ) : null}
+
+ );
+ }
+ return (
+ <>
+
+ Hello
+ >
+ );
+};
+
+export const DialogExample = () => {
+ function Example() {
+ const [open, setOpen] = React.useState(false);
+ const id = useId();
+
+ return (
+
+
+ First
+
+
{
+ setOpen(true);
+ }}>
+ Open
+
+ {open ? (
+
+
+ {
+ setOpen(false);
+ }}
+ style={{
+ position: 'relative',
+ zIndex: 9999,
+ padding: '1rem',
+ background: 'white',
+ }}>
+
+ Hello
+
+
+
+
+ {
+ setOpen(false);
+ }}>
+ Close
+
+
+
+ ) : null}
+
+
+ Last
+
+
+ );
+ }
+
+ 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"