diff --git a/packages/main/src/components/Modals/Modals.cy.tsx b/packages/main/src/components/Modals/Modals.cy.tsx index 9784606f268..6b4a6144d47 100644 --- a/packages/main/src/components/Modals/Modals.cy.tsx +++ b/packages/main/src/components/Modals/Modals.cy.tsx @@ -45,16 +45,33 @@ describe('Modals - static helpers', () => { > Show Popover +
+ ); + }; + cy.mount(); - cy.mount(); + cy.findByText('Show Popover').click(); + cy.findByText('Popover Content').should('be.visible'); + cy.findByText('Show Popover').click(); + cy.get('[ui5-popover]').should('have.length', 2); - cy.findByText('Show Popover').click(); - cy.findByText('Popover Content').should('be.visible'); - cy.findByText('Close').click(); - cy.findByText('Dialog Content').should('not.exist'); - }; + cy.findByText('Show Popover 2').click(); + cy.findByText('Popover 2').should('be.visible'); + cy.get('[ui5-popover]').should('have.length', 1); }); it('showResponsivePopover', () => { @@ -63,27 +80,43 @@ describe('Modals - static helpers', () => { <> } />, + Modals.showResponsivePopover({ + opener: 'modals-show-responsive-popover', + children: 'ResponsivePopover Content', }); }} > - Show Popover + Show ResponsivePopover + +
+ ); + }; + cy.mount(); - cy.mount(); + cy.findByText('Show ResponsivePopover').click(); + cy.findByText('ResponsivePopover Content').should('be.visible'); + cy.findByText('Show ResponsivePopover').click(); + cy.get('[ui5-responsive-popover]').should('have.length', 2); - cy.findByText('Show Popover').click(); - cy.findByText('Popover Content').should('be.visible'); - cy.findByText('Close').click(); - cy.findByText('Dialog Content').should('not.exist'); - }; + cy.findByText('Show ResponsivePopover 2').click(); + cy.findByText('ResponsivePopover 2').should('be.visible'); + cy.get('[ui5-responsive-popover]').should('have.length', 1); }); it('showMenu', () => { @@ -92,26 +125,42 @@ describe('Modals - static helpers', () => { <> + ); + }; + cy.mount(); - cy.mount(); + cy.findByText('Show Menu').click(); + cy.get('[ui5-menu-item][text="MenuItem 1"]').should('be.visible'); + cy.findByText('Show Menu').click(); + cy.get('[ui5-menu]').should('have.length', 2); - cy.findByText('Show Menu').click(); - cy.findByText('MenuItem').should('be.visible'); - cy.findByText('MenuItem').click(); - cy.findByText('MenuItem').should('not.exist'); - }; + cy.findByText('Show Menu 2').click(); + cy.get('[ui5-menu-item][text="MenuItem 2"]').should('be.visible'); + cy.get('[ui5-menu]').should('have.length', 1); }); it('showMessageBox', () => { diff --git a/packages/main/src/components/Modals/Modals.mdx b/packages/main/src/components/Modals/Modals.mdx index 830b66ddb38..b9dfc4b0d72 100644 --- a/packages/main/src/components/Modals/Modals.mdx +++ b/packages/main/src/components/Modals/Modals.mdx @@ -39,15 +39,26 @@ root.render( ```typescript import { Modals } from '@ui5/webcomponents-react/Modals'; +// Recommended: using config object +const { ref, close } = Modals.showDialog(props, config); + +// Legacy: using container directly const { ref, close } = Modals.showDialog(props, container); ``` **Parameters** -| Parameter | Description | -| ------------- | ------------------------------------------------------------------------------------- | -| `props` | All supported `Dialog` props (see table below). `open` will always be set to `true`. | -| _`container`_ | Optional container where the `Dialog` should be mounted. Defaults to `document.body`. | +| Parameter | Description | +| ------------- | ------------------------------------------------------------------------------------------------------- | +| `props` | All supported `Dialog` props (see table below). `open` will always be set to `true`. | +| _`config`_ | Optional configuration object. See config options below. | +| _`container`_ | _(deprecated)_ Optional container where the `Dialog` should be mounted. Use `config.container` instead. | + +**Config Options** _(since 2.19.0)_ + +| Property | Description | +| ----------- | -------------------------------------------------------------------------------------- | +| `container` | Optional container where the component should be mounted. Defaults to `document.body`. | **Return Value** @@ -71,15 +82,27 @@ The `showDialog` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react/Modals'; +// Recommended: using config object +const { ref, close } = Modals.showPopover(props, config); + +// Legacy: using container directly const { ref, close } = Modals.showPopover(props, container); ``` **Parameters** -| Parameter | Description | -| ------------- | -------------------------------------------------------------------------------------- | -| `props` | All supported `Popover` props (see table below).`open` will always be set to `true`. | -| _`container`_ | Optional container where the `Popover` should be mounted. Defaults to `document.body`. | +| Parameter | Description | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `props` | All supported `Popover` props (see table below). `open` will always be set to `true`. | +| _`config`_ | Optional configuration object. See config options below. | +| _`container`_ | _(deprecated)_ Optional container where the `Popover` should be mounted. Use `config.container` instead. | + +**Config Options** _(since 2.19.0)_ + +| Property | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `container` | Optional container where the component should be mounted. Defaults to `document.body`. | +| `autoClosePopovers` | If set to `true`, opening a new Popover will automatically close all currently opened Popovers that share the same opener. Only affects Popover, Menu, and ResponsivePopover. | **Return Value** @@ -103,15 +126,27 @@ The `showPopover` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react/Modals'; +// Recommended: using config object +const { ref, close } = Modals.showResponsivePopover(props, config); + +// Legacy: using container directly const { ref, close } = Modals.showResponsivePopover(props, container); ``` **Parameters** -| Parameter | Description | -| ------------- | ------------------------------------------------------------------------------------------------ | -| `props` | All supported `ResponsivePopover` props (see table below). `open` will always be set to `true`. | -| _`container`_ | Optional container where the `ResponsivePopover` should be mounted. Defaults to `document.body`. | +| Parameter | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------ | +| `props` | All supported `ResponsivePopover` props (see table below). `open` will always be set to `true`. | +| _`config`_ | Optional configuration object. See config options below. | +| _`container`_ | _(deprecated)_ Optional container where the `ResponsivePopover` should be mounted. Use `config.container` instead. | + +**Config Options** _(since 2.19.0)_ + +| Property | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `container` | Optional container where the component should be mounted. Defaults to `document.body`. | +| `autoClosePopovers` | If set to `true`, opening a new Popover will automatically close all currently opened Popovers that share the same opener. Only affects Popover, Menu, and ResponsivePopover. | **Return Value** @@ -137,19 +172,31 @@ The `showResponsivePopover` method returns an object with the following properti ```typescript import { Modals } from '@ui5/webcomponents-react/Modals'; +// Recommended: using config object +const { ref, close } = Modals.showMenu(props, config); + +// Legacy: using container directly const { ref, close } = Modals.showMenu(props, container); ``` **Parameters** -| Parameter | Description | -| ------------- | ----------------------------------------------------------------------------------- | -| `props` | All supported `Menu` props (see table below). `open` will always be set to `true`. | -| _`container`_ | Optional container where the `Menu` should be mounted. Defaults to `document.body`. | +| Parameter | Description | +| ------------- | ----------------------------------------------------------------------------------------------------- | +| `props` | All supported `Menu` props (see table below). `open` will always be set to `true`. | +| _`config`_ | Optional configuration object. See config options below. | +| _`container`_ | _(deprecated)_ Optional container where the `Menu` should be mounted. Use `config.container` instead. | + +**Config Options** _(since 2.19.0)_ + +| Property | Description | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `container` | Optional container where the component should be mounted. Defaults to `document.body`. | +| `autoClosePopovers` | If set to `true`, opening a new Popover will automatically close all currently opened Popovers that share the same opener. Only affects Popover, Menu, and ResponsivePopover. | **Return Value** -The `Menu` method returns an object with the following properties: +The `showMenu` method returns an object with the following properties: | Property | Description | | --------- | ---------------------------------------------------------------- | @@ -169,15 +216,26 @@ The `Menu` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react/Modals'; +// Recommended: using config object +const { ref, close } = Modals.showMessageBox(props, config); + +// Legacy: using container directly const { ref, close } = Modals.showMessageBox(props, container); ``` **Parameters** -| Parameter | Description | -| ------------- | ----------------------------------------------------------------------------------------- | -| `props` | All supported `MessageBox` props (see table below). `open` will always be set to `true`. | -| _`container`_ | Optional container where the `MessageBox` should be mounted. Defaults to `document.body`. | +| Parameter | Description | +| ------------- | ----------------------------------------------------------------------------------------------------------- | +| `props` | All supported `MessageBox` props (see table below). `open` will always be set to `true`. | +| _`config`_ | Optional configuration object. See config options below. | +| _`container`_ | _(deprecated)_ Optional container where the `MessageBox` should be mounted. Use `config.container` instead. | + +**Config Options** _(since 2.19.0)_ + +| Property | Description | +| ----------- | -------------------------------------------------------------------------------------- | +| `container` | Optional container where the component should be mounted. Defaults to `document.body`. | **Return Value** @@ -201,19 +259,30 @@ The `showMessageBox` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react/Modals'; +// Recommended: using config object +const { ref } = Modals.showToast(props, config); + +// Legacy: using container directly const { ref } = Modals.showToast(props, container); ``` **Parameters** -| Parameter | Description | -| ------------- | ----------------------------------------------------------------------------------- | -| `props` | All supported `Toast` props(see table below). | -| _`container`_ | Optional container where the `Toast` should be mounted.Defaults to `document.body`. | +| Parameter | Description | +| ------------- | ------------------------------------------------------------------------------------------------------ | +| `props` | All supported `Toast` props (see table below). | +| _`config`_ | Optional configuration object. See config options below. | +| _`container`_ | _(deprecated)_ Optional container where the `Toast` should be mounted. Use `config.container` instead. | + +**Config Options** _(since 2.19.0)_ + +| Property | Description | +| ----------- | -------------------------------------------------------------------------------------- | +| `container` | Optional container where the component should be mounted. Defaults to `document.body`. | **Return Value** -The`showToast` method returns an object with the following properties: +The `showToast` method returns an object with the following properties: | Property | Description | | -------- | ----------------------------------------------------------------- | diff --git a/packages/main/src/components/Modals/Modals.stories.tsx b/packages/main/src/components/Modals/Modals.stories.tsx index e8becf2e388..3a7a63744c7 100644 --- a/packages/main/src/components/Modals/Modals.stories.tsx +++ b/packages/main/src/components/Modals/Modals.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { FlexBox, FlexBoxJustifyContent } from '@ui5/webcomponents-react'; -import { MessageBoxType } from '../../enums/index.js'; -import { Button, MenuItem } from '../../webComponents/index.js'; +import { useState } from 'react'; +import { FlexBoxDirection, FlexBoxJustifyContent, MessageBoxType } from '../../enums/index.js'; +import { Button, Label, MenuItem, Switch } from '../../webComponents/index.js'; +import { FlexBox } from '../FlexBox/index.js'; import { Modals } from './index.js'; const meta = { @@ -38,68 +39,92 @@ export const Dialog: Story = { export const Popover = { render: () => { + const [autoClosePopovers, setAutoClosePopovers] = useState(false); return ( - <> + + + setAutoClosePopovers(e.target.checked)} /> + + - + ); }, }; export const ResponsivePopover = { render: () => { + const [autoClosePopovers, setAutoClosePopovers] = useState(false); return ( - <> + + + setAutoClosePopovers(e.target.checked)} /> + + - + ); }, }; export const Menu = { render: () => { + const [autoClosePopovers, setAutoClosePopovers] = useState(false); return ( - <> + + + setAutoClosePopovers(e.target.checked)} /> + + - + ); }, }; diff --git a/packages/main/src/components/Modals/index.tsx b/packages/main/src/components/Modals/index.tsx index 7e1b931f64d..c783df457e7 100644 --- a/packages/main/src/components/Modals/index.tsx +++ b/packages/main/src/components/Modals/index.tsx @@ -1,7 +1,7 @@ 'use client'; import type { MutableRefObject } from 'react'; -import { createRef, useSyncExternalStore } from 'react'; +import { createRef, useEffect, useRef, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { getRandomId } from '../../internal/getRandomId.js'; import { ModalStore } from '../../internal/ModalStore.js'; @@ -21,6 +21,32 @@ import { Toast } from '../../webComponents/Toast/index.js'; import type { MessageBoxPropTypes } from '../MessageBox/index.js'; import { MessageBox } from '../MessageBox/index.js'; +/** + * @since 2.19.0 + */ +interface ModalConfig { + /** + * Optional container where the component should be mounted. + * + * @default `document.body` + */ + container?: Element | DocumentFragment; +} + +/** + * @since 2.19.0 + */ +interface ModalConfigPopover extends ModalConfig { + /** + * If set to `true`, opening a new Popover will automatically close all currently opened Popovers that share the __same opener__. + * + * __Note:__ + * - This only affects `Popover`, `Menu`, and `ResponsivePopover`. + * - Only closes open Popovers with the __same opener__. + */ + autoClosePopovers?: boolean; +} + type ModalReturnType = { ref: MutableRefObject; }; @@ -29,10 +55,29 @@ type ClosableModalReturnType = ModalReturnType & { close: () => void; }; +function autoClose(props: { opener?: PopoverPropTypes['opener'] }) { + const openPopovers = ModalStore.getPopoversWithSameOpener(props.opener); + openPopovers.forEach((popover) => { + const popoverRef = popover.ref as MutableRefObject; + if (popoverRef.current) { + popoverRef.current.open = false; + } + }); +} + +function showDialogFn(props: DialogPropTypes, config?: ModalConfig): ClosableModalReturnType; +/** + * @deprecated Passing `container` directly is deprecated. Use the `config` object with `config.container` instead. + */ function showDialogFn( props: DialogPropTypes, container?: Element | DocumentFragment, +): ClosableModalReturnType; +function showDialogFn( + props: DialogPropTypes, + containerOrConfig?: Element | DocumentFragment | ModalConfig, ): ClosableModalReturnType { + const isContainer = containerOrConfig instanceof Element || containerOrConfig instanceof DocumentFragment; const id = getRandomId(); const ref = createRef(); ModalStore.addModal({ @@ -48,7 +93,7 @@ function showDialogFn( }, }, ref, - container, + container: isContainer ? containerOrConfig : containerOrConfig?.container, id, }); @@ -62,12 +107,24 @@ function showDialogFn( }; } +function showPopoverFn(props: PopoverPropTypes, config?: ModalConfigPopover): ClosableModalReturnType; +/** + * @deprecated Passing `container` directly is deprecated. Use the `config` object with `config.container` instead. + */ function showPopoverFn( props: PopoverPropTypes, container?: Element | DocumentFragment, +): ClosableModalReturnType; +function showPopoverFn( + props: PopoverPropTypes, + containerOrConfig?: Element | DocumentFragment | ModalConfigPopover, ): ClosableModalReturnType { + const isContainer = containerOrConfig instanceof Element || containerOrConfig instanceof DocumentFragment; const id = getRandomId(); const ref = createRef(); + if (!isContainer && containerOrConfig?.autoClosePopovers) { + autoClose(props); + } ModalStore.addModal({ Component: Popover, props: { @@ -81,7 +138,7 @@ function showPopoverFn( }, }, ref, - container, + container: isContainer ? containerOrConfig : containerOrConfig?.container, id, }); @@ -95,12 +152,27 @@ function showPopoverFn( }; } +function showResponsivePopoverFn( + props: ResponsivePopoverPropTypes, + config?: ModalConfigPopover, +): ClosableModalReturnType; +/** + * @deprecated Passing `container` directly is deprecated. Use the `config` object with `config.container` instead. + */ function showResponsivePopoverFn( props: ResponsivePopoverPropTypes, container?: Element | DocumentFragment, +): ClosableModalReturnType; +function showResponsivePopoverFn( + props: ResponsivePopoverPropTypes, + containerOrConfig?: Element | DocumentFragment | ModalConfigPopover, ): ClosableModalReturnType { + const isContainer = containerOrConfig instanceof Element || containerOrConfig instanceof DocumentFragment; const id = getRandomId(); const ref = createRef(); + if (!isContainer && containerOrConfig?.autoClosePopovers) { + autoClose(props); + } ModalStore.addModal({ Component: ResponsivePopover, props: { @@ -114,7 +186,7 @@ function showResponsivePopoverFn( }, }, ref, - container, + container: isContainer ? containerOrConfig : containerOrConfig?.container, id, }); @@ -128,9 +200,21 @@ function showResponsivePopoverFn( }; } -function showMenuFn(props: MenuPropTypes, container?: Element | DocumentFragment): ClosableModalReturnType { +function showMenuFn(props: MenuPropTypes, config?: ModalConfigPopover): ClosableModalReturnType; +/** + * @deprecated Passing `container` directly is deprecated. Use the `config` object with `config.container` instead. + */ +function showMenuFn(props: MenuPropTypes, container?: Element | DocumentFragment): ClosableModalReturnType; +function showMenuFn( + props: MenuPropTypes, + containerOrConfig?: Element | DocumentFragment | ModalConfigPopover, +): ClosableModalReturnType { + const isContainer = containerOrConfig instanceof Element || containerOrConfig instanceof DocumentFragment; const id = getRandomId(); const ref = createRef(); + if (!isContainer && containerOrConfig?.autoClosePopovers) { + autoClose(props); + } ModalStore.addModal({ Component: Menu, props: { @@ -144,7 +228,7 @@ function showMenuFn(props: MenuPropTypes, container?: Element | DocumentFragment }, }, ref, - container, + container: isContainer ? containerOrConfig : containerOrConfig?.container, id, }); @@ -158,10 +242,19 @@ function showMenuFn(props: MenuPropTypes, container?: Element | DocumentFragment }; } +function showMessageBoxFn(props: MessageBoxPropTypes, config?: ModalConfig): ClosableModalReturnType; +/** + * @deprecated Passing `container` directly is deprecated. Use the `config` object with `config.container` instead. + */ function showMessageBoxFn( props: MessageBoxPropTypes, container?: Element | DocumentFragment, +): ClosableModalReturnType; +function showMessageBoxFn( + props: MessageBoxPropTypes, + containerOrConfig?: Element | DocumentFragment | ModalConfig, ): ClosableModalReturnType { + const isContainer = containerOrConfig instanceof Element || containerOrConfig instanceof DocumentFragment; const id = getRandomId(); const ref = createRef(); ModalStore.addModal({ @@ -178,7 +271,7 @@ function showMessageBoxFn( }, }, ref, - container, + container: isContainer ? containerOrConfig : containerOrConfig?.container, id, }); @@ -192,7 +285,16 @@ function showMessageBoxFn( }; } -function showToastFn(props: ToastPropTypes, container?: Element | DocumentFragment): ModalReturnType { +function showToastFn(props: ToastPropTypes, config?: ModalConfig): ModalReturnType; +/** + * @deprecated Passing `container` directly is deprecated. Use the `config` object with `config.container` instead. + */ +function showToastFn(props: ToastPropTypes, container?: Element | DocumentFragment): ModalReturnType; +function showToastFn( + props: ToastPropTypes, + containerOrConfig?: Element | DocumentFragment | ModalConfig, +): ModalReturnType { + const isContainer = containerOrConfig instanceof Element || containerOrConfig instanceof DocumentFragment; const ref = createRef(); const id = getRandomId(); ModalStore.addModal({ @@ -208,7 +310,7 @@ function showToastFn(props: ToastPropTypes, container?: Element | DocumentFragme }, }, ref, - container, + container: isContainer ? containerOrConfig : containerOrConfig?.container, id, }); @@ -229,6 +331,16 @@ function showToastFn(props: ToastPropTypes, container?: Element | DocumentFragme */ export function Modals() { const modals = useSyncExternalStore(ModalStore.subscribe, ModalStore.getSnapshot, ModalStore.getServerSnapshot); + const modalsRef = useRef(modals); + modalsRef.current = modals; + + useEffect(() => { + return () => { + modalsRef.current.forEach((modal) => { + ModalStore.removeModal(modal.id); + }); + }; + }, []); return ( <> diff --git a/packages/main/src/internal/ModalStore.ts b/packages/main/src/internal/ModalStore.ts index 31ffcd83a7f..e6727946d82 100644 --- a/packages/main/src/internal/ModalStore.ts +++ b/packages/main/src/internal/ModalStore.ts @@ -1,5 +1,6 @@ import { getCurrentRuntimeIndex } from '@ui5/webcomponents-base/dist/Runtimes.js'; import type { ComponentType, RefCallback, RefObject } from 'react'; +import type { PopoverPropTypes } from '../webComponents/Popover/index.js'; globalThis['@ui5/webcomponents-react'] ??= {}; const STORE_LOCATION = globalThis['@ui5/webcomponents-react']; @@ -60,4 +61,7 @@ export const ModalStore = { STORE_LOCATION[getStyleStoreSymbol()] = getSnapshot().filter((modal) => modal.id !== id); emitChange(); }, + getPopoversWithSameOpener(opener: PopoverPropTypes['opener']) { + return getSnapshot().filter((popover) => popover.props?.opener === opener); + }, };