diff --git a/packages/react/src/components/Dialog/Dialog-story.js b/packages/react/src/components/Dialog/Dialog-story.js index d3a9ecaf7733..7bbcc6445c21 100644 --- a/packages/react/src/components/Dialog/Dialog-story.js +++ b/packages/react/src/components/Dialog/Dialog-story.js @@ -7,11 +7,10 @@ /* eslint-disable storybook/story-exports */ -import * as React from 'react'; -import { FocusScope } from '../FocusScope'; -import { Dialog } from '../Dialog'; -import { useId } from '../../internal/useId'; -import { Portal } from '../Portal'; +import React, { useEffect, useState } from 'react'; +import Dialog from './'; +import Button from '../Button'; +import { action } from '@storybook/addon-actions'; export default { title: 'Experimental/unstable_Dialog', @@ -19,137 +18,65 @@ export default { includeStories: [], }; -export const Default = () => { - function DemoComponent() { - const [open, setOpen] = React.useState(false); - const ref = React.useRef(null); +export const Default = ({ open: _open, ...args }) => { + const [open, setOpen] = useState(_open); - 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 ( - <> - - - - ); -}; + const handleOpenDialog = () => { + setOpen(!open); + }; -export const DialogExample = () => { - function Example() { - const [open, setOpen] = React.useState(false); - const id = useId(); + const closeAction = action('Close action'); - return ( -
-
- -
- - {open ? ( - - - { - setOpen(false); - }} - style={{ - position: 'relative', - zIndex: 9999, - padding: '1rem', - background: 'white', - }}> -
- Hello -
-
- -
- -
-
- ) : null} + const handleCloseEvent = () => { + closeAction(); + // keep local state the same as the dialog otherwise open will + // need two clicks after a close + setOpen(false); + }; -
- -
-
- ); - } + const handleCloseClick = () => { + setOpen(false); + }; - return ; -}; + useEffect(() => { + setOpen(_open); + }, [_open]); -const FullPage = React.forwardRef(function FullPage(props, ref) { return ( -
+
+ + +

+ 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. +

+

+

+ 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. +

+

+

+ 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. +

+

+ +
+
); -}); +}; diff --git a/packages/react/src/components/Dialog/index.js b/packages/react/src/components/Dialog/index.js deleted file mode 100644 index 4f13a9273c07..000000000000 --- a/packages/react/src/components/Dialog/index.js +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2023 - * - * 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/Dialog/index.tsx b/packages/react/src/components/Dialog/index.tsx new file mode 100644 index 000000000000..dcc9cd3f5032 --- /dev/null +++ b/packages/react/src/components/Dialog/index.tsx @@ -0,0 +1,150 @@ +/** + * Copyright IBM Corp. 2014, 2024 + * + * 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, { MutableRefObject, useEffect, useRef } from 'react'; +import { usePrefix } from '../../internal/usePrefix'; +import cx from 'classnames'; +import { Close } from '@carbon/icons-react'; +import { IconButton } from '../IconButton'; +import { noopFn } from '../../internal/noopFn'; +import { ReactAttr } from '../../types/common'; + +export interface DialogProps extends ReactAttr { + /** + * Provide the contents of the Dialog + */ + children?: React.ReactNode; + + /** + * Specifies whether the dialog is modal or non-modal + */ + modal?: boolean; + + /** + * Specify a handler for closing Dialog. + * The handler should care of closing Dialog, e.g. changing `open` prop. + */ + onRequestClose?: React.ReactEventHandler; + + /** + * Specify whether the Dialog is currently open + */ + open?: boolean; +} + +const Dialog = React.forwardRef( + ( + { + children, + modal, + onRequestClose = noopFn, + open = false, + ...rest + }: DialogProps, + ref + ) => { + const backupRef = useRef(null); + const localRef = (ref ?? backupRef) as MutableRefObject; + + const prefix = usePrefix(); + + const handleClose = (ev) => { + if (onRequestClose) { + onRequestClose(ev); + } + }; + + const handleCancel = () => { + localRef.current?.close(); + }; + + const handleBackdropClick = (ev) => { + if (ev.target === localRef.current) { + localRef.current?.close(); + } + }; + + useEffect(() => { + if (localRef.current) { + if (open) { + if (modal) { + localRef.current.showModal(); + } else { + localRef.current.open = true; + } + } else { + localRef.current.close(); + } + } + }, [localRef, modal, open]); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions + +
+
+ + +
+
+ +
{children}
+
+ ); + } +); + +Dialog.displayName = 'Dialog'; + +Dialog.propTypes = { + /** + * Provide children to be rendered inside of the Dialog + */ + children: PropTypes.node, + + /** + * Modal specifies whether the Dialog is modal or non-modal + */ + modal: PropTypes.bool, + + /** + * Specify a handler for closing Dialog. + * The handler should care of closing Dialog, e.g. changing `open` prop. + */ + onRequestClose: PropTypes.func, + + /** + * open initial state + */ + open: PropTypes.bool, +}; + +export { Dialog }; +export default Dialog; diff --git a/packages/react/src/components/FocusScope/index.js b/packages/react/src/components/FocusScope/index.js deleted file mode 100644 index 9c59e549b077..000000000000 --- a/packages/react/src/components/FocusScope/index.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2023 - * - * 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 deleted file mode 100644 index 4a1c082fbb49..000000000000 --- a/packages/react/src/components/FocusScope/useAutoFocus.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2023 - * - * 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 deleted file mode 100644 index 41ab64a28ab6..000000000000 --- a/packages/react/src/components/FocusScope/useFocusScope.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2023 - * - * 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 deleted file mode 100644 index 16667593efd2..000000000000 --- a/packages/react/src/components/FocusScope/useRestoreFocus.js +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2023 - * - * 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/styles/scss/components/dialog/_dialog.scss b/packages/styles/scss/components/dialog/_dialog.scss new file mode 100644 index 000000000000..76d9bdc93345 --- /dev/null +++ b/packages/styles/scss/components/dialog/_dialog.scss @@ -0,0 +1,155 @@ +// +// Copyright IBM Corp. 2014, 2024 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@use 'sass:list'; +@use '../button'; +@use '../../config' as *; +@use '../../breakpoint' as *; +@use '../../motion' as *; +@use '../../spacing' as *; +@use '../../theme' as *; +@use '../../type' as *; +@use '../../utilities/ai-gradient' as *; +@use '../../utilities/convert'; +@use '../../utilities/component-reset'; +@use '../../utilities/focus-outline' as *; +@use '../../utilities/high-contrast-mode' as *; +@use '../../utilities/z-index' as *; + +/// Dialog styles +/// @access public +/// @group dialog +@mixin dialog { + .#{$prefix}--dialog { + /* size */ + padding: 0; + border: 1px solid $border-subtle-01; + background-color: $layer; + color: $text-primary; + inline-size: 48rem; + max-block-size: 50%; + max-inline-size: 100%; + opacity: 0; + transform: translateY(calc(-1 * #{$spacing-06})); + + /** opening and closing is used in as allow-discrete is not currently supported wide enough + * https://caniuse.com/mdn-css_properties_display_is_transitionable + */ + transition: opacity $duration-moderate-02 motion(exit, expressive), + transform $duration-moderate-02 motion(exit, expressive), + overlay $duration-moderate-02 motion(exit, expressive) allow-discrete, + display $duration-moderate-02 motion(exit, expressive) allow-discrete; + + @media (prefers-reduced-motion) { + transition: none; + } + + @include breakpoint(md) { + max-inline-size: 84%; + } + @include breakpoint(lg) { + max-inline-size: 72%; + } + @include breakpoint(xlg) { + max-inline-size: 64%; + } + @include breakpoint(xlg) { + max-inline-size: 60%; + } + + &[open] { + opacity: 1; + transform: translateY(0); + + transition: opacity $duration-moderate-02 motion(entrance, expressive), + transform $duration-moderate-02 motion(entrance, expressive), + overlay $duration-moderate-02 motion(entrance, expressive) + allow-discrete, + display $duration-moderate-02 motion(entrance, expressive) + allow-discrete; + + @media (prefers-reduced-motion) { + transition: none; + } + } + + /** starting style also not supported widely + * https://caniuse.com/mdn-css_at-rules_starting-style + */ + /* Before-open state */ + /* Needs to be after the previous dialog[open] rule to take effect, + as the specificity is the same */ + /* stylelint-disable-next-line scss/at-rule-no-unknown */ + @starting-style { + &[open] { + opacity: 0; + transform: translateY(calc(-1 * #{$spacing-06})); + } + } + } + + .#{$prefix}--dialog__header { + position: relative; + overflow: visible; + inline-size: 100%; + min-block-size: $spacing-09; + } + + .#{$prefix}--dialog__content { + padding: $spacing-05; + block-size: 100%; + } + + .#{$prefix}--dialog--modal { + border: 1px solid transparent; + } + + /* Transition the :backdrop when the dialog modal is promoted to the top layer */ + .#{$prefix}--dialog::backdrop { + background-color: $overlay; + opacity: 0; + /* opening and closing is used in as allow-discrete is not currently supported wide enough + * https://caniuse.com/mdn-css_properties_display_is_transitionable + */ + transition: background-color $duration-moderate-02 + motion(entrance, expressive), + opacity $duration-moderate-02 motion(entrance, expressive); + + @media (prefers-reduced-motion) { + transition: none; + } + } + + .#{$prefix}--dialog[open]::backdrop { + opacity: 1; + + transition: background-color $duration-moderate-02 motion(exit, expressive), + opacity $duration-moderate-02 motion(exit, expressive); + + @media (prefers-reduced-motion) { + transition: none; + } + } + + /** starting style also not supported widely + * https://caniuse.com/mdn-css_at-rules_starting-style + */ + /* This starting-style rule cannot be nested inside the above selector +because the nesting selector cannot represent pseudo-elements. */ + /* stylelint-disable-next-line scss/at-rule-no-unknown */ + @starting-style { + .#{$prefix}--dialog[open]::backdrop { + opacity: 0; + } + } + + .#{$prefix}--dialog__header-controls { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + } +} diff --git a/packages/styles/scss/components/dialog/_index.scss b/packages/styles/scss/components/dialog/_index.scss new file mode 100644 index 000000000000..c53a964fb276 --- /dev/null +++ b/packages/styles/scss/components/dialog/_index.scss @@ -0,0 +1,11 @@ +// +// Copyright IBM Corp. 2018, 2023 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@forward 'dialog'; +@use 'dialog'; + +@include dialog.dialog;