diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 58380cd4be3..6ab6734c2eb 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -4,7 +4,7 @@ import { Preview } from '@storybook/react'; import { setLanguage } from '@ui5/webcomponents-base/dist/config/Language.js'; import { setTheme } from '@ui5/webcomponents-base/dist/config/Theme.js'; import applyDirection from '@ui5/webcomponents-base/dist/locale/applyDirection.js'; -import { ContentDensity, ThemeProvider } from '@ui5/webcomponents-react'; +import { ContentDensity, Modals, ThemeProvider } from '@ui5/webcomponents-react'; import { useEffect } from 'react'; import 'tocbot/dist/tocbot.css'; import '../packages/main/dist/Assets.js'; @@ -27,7 +27,7 @@ const preview: Preview = { } }, decorators: [ - (Story, { globals }) => { + (Story, { globals, viewMode }) => { const { theme, contentDensity, direction, language } = globals; useEffect(() => { @@ -57,6 +57,7 @@ const preview: Preview = { return ( + {viewMode !== 'docs' && } ); diff --git a/docs/MigrationGuide.mdx b/docs/MigrationGuide.mdx index ba0aa1b1072..7002bd64a21 100644 --- a/docs/MigrationGuide.mdx +++ b/docs/MigrationGuide.mdx @@ -610,6 +610,19 @@ function MyComponent() { ``` +### Modals + +All Modal helper hooks have been removed. They can be replaced with the regular methods: + +- `useShowDialog` --> `showDialog` +- `useShowPopover` --> `showPopover` +- `useShowResponsivePopover` --> `showResponsivePopover` +- `useShowMenu` --> `showMenu` +- `useShowMessageBox` --> `showMessageBox` +- `useShowToast` --> `showToast` + +The regular methods are now general purpose, so they can be used both inside the React content (components) as well as outside of the React context (redux, redux-saga, etc.). + ### ObjectPageSection The prop `titleText` is now required and the default value `true` has been removed for the `titleTextUppercase` prop to comply with the updated Fiori design guidelines. @@ -622,6 +635,11 @@ The prop `titleText` is now required. For better alignment with the UI5 Web Components the `active` prop has been renamed to `interactive`. +### ThemeProvider + +The prop `withoutModalsProvider` has been removed. +In order to provide a place for the `Modals` helper to mount the popovers, you have to render the new `Modals` component in your application tree. + ## Enum Changes For better alignment with the UI5 Web Components, the following enums have been renamed: diff --git a/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json b/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json index 7d49db5296e..e5aff80fb45 100644 --- a/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json +++ b/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json @@ -486,6 +486,9 @@ "valueState": "ValueState" } }, + "ThemeProvider": { + "removedProps": ["withoutModalsProvider"] + }, "TimePicker": { "renamedEnums": { "valueState": "ValueState" diff --git a/packages/main/src/components/Modals/Modals.cy.tsx b/packages/main/src/components/Modals/Modals.cy.tsx index 0f1a0fd6de1..47fe2ee48e4 100644 --- a/packages/main/src/components/Modals/Modals.cy.tsx +++ b/packages/main/src/components/Modals/Modals.cy.tsx @@ -6,6 +6,7 @@ describe('Modals - static helpers', () => { const TestComp = () => { return ( <> + { const { close } = Modals.showDialog({ @@ -30,18 +31,21 @@ describe('Modals - static helpers', () => { it('showPopover', () => { const TestComp = () => { return ( - { - const { close } = Modals.showPopover({ - opener: 'modals-show-popover', - children: 'Popover Content', - footer: close()}>Close} /> - }); - }} - > - Show Popover - + <> + + { + const { close } = Modals.showPopover({ + opener: 'modals-show-popover', + children: 'Popover Content', + footer: close()}>Close} /> + }); + }} + > + Show Popover + + > ); cy.mount(); @@ -56,18 +60,21 @@ describe('Modals - static helpers', () => { it('showResponsivePopover', () => { const TestComp = () => { return ( - { - const { close } = Modals.showResponsivePopover({ - opener: 'modals-show-popover', - children: 'Popover Content', - footer: close()}>Close} /> - }); - }} - > - Show Popover - + <> + + { + const { close } = Modals.showResponsivePopover({ + opener: 'modals-show-popover', + children: 'Popover Content', + footer: close()}>Close} /> + }); + }} + > + Show Popover + + > ); cy.mount(); @@ -82,17 +89,20 @@ describe('Modals - static helpers', () => { it('showMenu', () => { const TestComp = () => { return ( - { - Modals.showMenu({ - opener: 'modals-show-popover', - children: - }); - }} - > - Show Menu - + <> + + { + Modals.showMenu({ + opener: 'modals-show-popover', + children: + }); + }} + > + Show Menu + + > ); cy.mount(); @@ -108,6 +118,7 @@ describe('Modals - static helpers', () => { const TestComp = () => { return ( <> + { Modals.showMessageBox({ @@ -131,20 +142,23 @@ describe('Modals - static helpers', () => { it('showToast', () => { const TestComp = () => { return ( - - { - Modals.showToast( - { - children: 'Toast Content' - }, - document.getElementById('container') - ); - }} - > - Show Toast - - + <> + + + { + Modals.showToast( + { + children: 'Toast Content' + }, + document.getElementById('container') + ); + }} + > + Show Toast + + + > ); }; cy.mount(); @@ -153,136 +167,3 @@ describe('Modals - static helpers', () => { cy.findByText('Toast Content').should('exist'); }); }); - -describe('Modals - hooks', () => { - interface PropTypes { - hookFn: any; - modalProps: any; - } - const TestComponent = ({ hookFn, modalProps }: PropTypes) => { - const hook = hookFn(); - - return ( - { - hook(modalProps); - }} - > - Open Modal - - ); - }; - - const TestComponentClosable = ({ hookFn, modalProps }: PropTypes) => { - const hook = hookFn(); - - return ( - { - const { close } = hook({ - ...modalProps, - children: [ - ...modalProps?.children, - close()}> - Close - - ] - }); - }} - > - Open Modal - - ); - }; - - it('useShowDialog', () => { - cy.mount( - - ); - cy.findByText('Open Modal').click(); - cy.findByText('Dialog Content').should('be.visible'); - cy.findByText('Close').click(); - cy.findByText('Dialog Content').should('not.exist'); - }); - - it('useShowPopover', () => { - cy.mount( - <> - - - > - ); - cy.findByText('Open Modal').click(); - cy.findByText('Popover Content').should('be.visible'); - cy.findByText('Close').click(); - cy.findByText('Popover Content').should('not.exist'); - }); - - it('useShowResponsivePopover', () => { - cy.mount( - <> - - - > - ); - cy.findByText('Open Modal').click(); - cy.findByText('Popover Content').should('be.visible'); - cy.findByText('Close').click(); - cy.findByText('Popover Content').should('not.exist'); - }); - - it('useShowMenu', () => { - const TestComp = () => { - const showMenu = Modals.useShowMenu(); - return ( - - { - showMenu( - { - opener: 'modals-show-popover', - children: - }, - document.getElementById('container') - ); - }} - > - Show Menu - - - ); - }; - - cy.mount(); - - cy.findByText('Show Menu').click(); - cy.get('[ui5-menu-item]').click(); - cy.get('[ui5-menu]').should('not.exist'); - }); - - it('useShowMessageBox', () => { - cy.mount(); - cy.findByText('Open Modal').click(); - cy.findByText('MessageBox Content').should('be.visible'); - cy.findByText('OK').click(); - cy.findByText('MessageBox Content').should('not.exist'); - }); - - it('useShowToast', () => { - cy.mount(); - cy.findByText('Open Modal').click(); - cy.findByText('Toast Content').should('exist'); - }); -}); diff --git a/packages/main/src/components/Modals/Modals.mdx b/packages/main/src/components/Modals/Modals.mdx index 61ac5f4b994..71027296e25 100644 --- a/packages/main/src/components/Modals/Modals.mdx +++ b/packages/main/src/components/Modals/Modals.mdx @@ -3,31 +3,31 @@ import { ArgTypes, Canvas, Meta } from '@storybook/blocks'; import { Dialog, Menu, Panel, Popover, ResponsivePopover, Toast } from '../../webComponents/index'; import { MessageBox } from '../MessageBox'; import * as ComponentStories from './Modals.stories'; +import { Modals } from './index.tsx'; + + - - -## Usage Notes - -**In order to use these helpers, please make sure that your application is wrapped in the `` component.** +## General Usage Information -We are offering those helpers methods both as hooks and static methods: +Only one `Modals` component (``) should be rendered for each application, otherwise multiple popovers or dialogs are displayed. -`Modals.useShowXZY` +Example for mounting the `Modals` component: -Use this hook when you are in a React context where you are allowed to use hooks. -Calling the hook returns a memoized function, which you can execute to show the popup by passing the props and an optional container. -**This should always be the preferred option!** - -`Modals.showXZY` - -Use this static helper in case you are not in a React context (-> you can't use hooks), e.g. in a `redux` reducer. -You can pass the props and an optional container directly. - - +```jsx +import { Modals, ThemeProvider } from '@ui5/webcomponents-react'; +... +const root = createRoot(document.getElementById("root")); +root.render( + + + + +); +``` ## Dialog @@ -38,11 +38,6 @@ You can pass the props and an optional container directly. ```typescript import { Modals } from '@ui5/webcomponents-react'; -// when you can use hooks -const showDialog = Modals.useShowDialog(); -const { ref, close } = showDialog(props, container); - -// when you can't use hooks (e.g. inside a redux reducer) const { ref, close } = Modals.showDialog(props, container); ``` @@ -75,11 +70,6 @@ The `showDialog` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react'; -// when you can use hooks -const showPopover = Modals.useShowPopover(); -const { ref, close } = showPopover(props, container); - -// when you can't use hooks (e.g. inside a redux reducer) const { ref, close } = Modals.showPopover(props, container); ``` @@ -112,11 +102,6 @@ The `showPopover` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react'; -// when you can use hooks -const showResponsivePopover = Modals.useShowResponsivePopover(); -const { ref, close } = showResponsivePopover(props, container); - -// when you can't use hooks (e.g. inside a redux reducer) const { ref, close } = Modals.showResponsivePopover(props, container); ``` @@ -151,11 +136,6 @@ The `showResponsivePopover` method returns an object with the following properti ```typescript import { Modals } from '@ui5/webcomponents-react'; -// when you can use hooks -const showMenu = Modals.useShowMenu(); -const { ref, close } = showMenu(props, container); - -// when you can't use hooks (e.g. inside a redux reducer) const { ref, close } = Modals.showMenu(props, container); ``` @@ -188,11 +168,6 @@ The `Menu` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react'; -// when you can use hooks -const showMessageBox = Modals.useShowMessageBox(); -const { ref, close } = showMessageBox(props, container); - -// when you can't use hooks (e.g. inside a redux reducer) const { ref, close } = Modals.showMessageBox(props, container); ``` @@ -225,11 +200,6 @@ The `showMessageBox` method returns an object with the following properties: ```typescript import { Modals } from '@ui5/webcomponents-react'; -// when you can use hooks -const showToast = Modals.useShowToast(); -const { ref } = showToast(props, container); - -// when you can't use hooks (e.g. inside a redux reducer) const { ref } = Modals.showToast(props, container); ``` diff --git a/packages/main/src/components/Modals/Modals.stories.tsx b/packages/main/src/components/Modals/Modals.stories.tsx index a5454027dd0..5b645b9fedb 100644 --- a/packages/main/src/components/Modals/Modals.stories.tsx +++ b/packages/main/src/components/Modals/Modals.stories.tsx @@ -4,126 +4,133 @@ import { Bar, Button, MenuItem } from '../../webComponents/index.js'; import { Modals } from './index.js'; const meta = { - title: 'User Feedback / Modals' + title: 'User Feedback / Modals', + component: Modals } satisfies Meta; export default meta; type Story = StoryObj; export const Dialog: Story = { render: () => { - const showDialog = Modals.useShowDialog(); return ( - { - const { close } = showDialog({ - headerText: 'Dialog Title', - children: "I'm a Dialog!", - footer: close()}>Close} /> - }); - }} - > - Show Dialog - + <> + { + const { close } = Modals.showDialog({ + headerText: 'Dialog Title', + children: "I'm a Dialog!", + footer: close()}>Close} /> + }); + }} + > + Show Dialog + + > ); } }; export const Popover = { render: () => { - const showPopover = Modals.useShowPopover(); return ( - { - showPopover({ - opener: 'modals-show-popover', - headerText: 'Popover Title', - children: "I'm a Popover!" - }); - }} - > - Show Popover - + <> + { + Modals.showPopover({ + opener: 'modals-show-popover', + headerText: 'Popover Title', + children: "I'm a Popover!" + }); + }} + > + Show Popover + + > ); } }; export const ResponsivePopover = { render: () => { - const showResponsivePopover = Modals.useShowResponsivePopover(); return ( - { - showResponsivePopover({ - opener: 'modals-show-responsive-popover', - headerText: 'Responsive Popover Title', - children: "I'm a Responsive Popover!" - }); - }} - > - Show ResponsivePopover - + <> + { + Modals.showResponsivePopover({ + opener: 'modals-show-responsive-popover', + headerText: 'Responsive Popover Title', + children: "I'm a Responsive Popover!" + }); + }} + > + Show ResponsivePopover + + > ); } }; export const Menu = { render: () => { - const showMenu = Modals.useShowMenu(); return ( - { - showMenu({ - opener: 'modals-show-menu', - headerText: 'Menu Title', - children: ( - <> - - - > - ) - }); - }} - > - Show Menu - + <> + { + Modals.showMenu({ + opener: 'modals-show-menu', + headerText: 'Menu Title', + children: ( + <> + + + > + ) + }); + }} + > + Show Menu + + > ); } }; export const MessageBox = { render: () => { - const showMessageBox = Modals.useShowMessageBox(); return ( - { - showMessageBox({ - type: MessageBoxType.Confirm, - children: 'Can you see this MessageBox?' - }); - }} - > - Show MessageBox - + <> + { + Modals.showMessageBox({ + type: MessageBoxType.Confirm, + children: 'Can you see this MessageBox?' + }); + }} + > + Show MessageBox + + > ); } }; export const Toast = { render: () => { - const showToast = Modals.useShowToast(); return ( - { - showToast({ - children: "I'm a Message Toast!" - }); - }} - > - Show Toast - + <> + { + Modals.showToast({ + children: "I'm a Message Toast!" + }); + }} + > + Show Toast + + > ); } }; diff --git a/packages/main/src/components/Modals/ModalsProvider.tsx b/packages/main/src/components/Modals/ModalsProvider.tsx deleted file mode 100644 index 988c5838f89..00000000000 --- a/packages/main/src/components/Modals/ModalsProvider.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type { ReactNode } from 'react'; -import { useMemo, useReducer } from 'react'; -import { createPortal } from 'react-dom'; -import type { ModalState, UpdateModalStateAction } from '../../internal/ModalsContext.js'; -import { getModalContext } from '../../internal/ModalsContext.js'; - -export interface ModalsProviderPropTypes { - children: ReactNode; -} - -//@ts-expect-error: can't assume state generics at this point -const modalStateReducer = (state: ModalState[], action: UpdateModalStateAction) => { - switch (action.type) { - case 'set': - return [...state, action.payload]; - case 'reset': - return state.filter((modal) => modal.id !== action.payload.id); - default: - return state; - } -}; - -export function ModalsProvider({ children }: ModalsProviderPropTypes) { - const [modals, setModal] = useReducer(modalStateReducer, []); - - // necessary for static method - globalThis['@ui5/webcomponents-react'] ??= {}; - globalThis['@ui5/webcomponents-react'].setModal = setModal; - - const GlobalModalsContext = getModalContext(); - const memoizedVal = useMemo( - () => ({ - setModal: globalThis['@ui5/webcomponents-react'].setModal - }), - [] - ); - - return ( - - {modals.map((modal) => { - if (modal?.Component) { - return createPortal( - , - modal.container ?? document.body - ); - } - })} - {children} - - ); -} diff --git a/packages/main/src/components/Modals/index.tsx b/packages/main/src/components/Modals/index.tsx index 69fb26bac63..a30f0e5507b 100644 --- a/packages/main/src/components/Modals/index.tsx +++ b/packages/main/src/components/Modals/index.tsx @@ -1,10 +1,11 @@ 'use client'; -import type { Dispatch, MutableRefObject, RefObject } from 'react'; -import { createRef, useCallback } from 'react'; +import type { RefObject } from 'react'; +import { createRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js'; import { getRandomId } from '../../internal/getRandomId.js'; -import type { UpdateModalStateAction } from '../../internal/ModalsContext.js'; -import { useModalsContext } from '../../internal/ModalsContext.js'; +import { ModalStore } from '../../internal/ModalStore.js'; import type { DialogDomRef, DialogPropTypes, @@ -29,229 +30,28 @@ type ClosableModalReturnType = ModalReturnType & { close: () => void; }; -type ModalHookReturnType = ( - props: Props, - container?: ContainerElement -) => ModalReturnType; -type CloseableModalHookReturnType = ( - props: Props, - container?: ContainerElement -) => ClosableModalReturnType; - -const checkContext = (context: any): void => { - if (!context) { - // eslint-disable-next-line no-console - console.error(`Please make sure that your application is wrapped in the '' component.`); - } -}; - -function showDialog( +function showDialogFn( props: DialogPropTypes, - setModal: Dispatch>, - container?: ContainerElement -) { - checkContext(setModal); - const id = getRandomId(); - const ref = createRef(); - setModal?.({ - type: 'set', - payload: { - Component: Dialog, - props: { - ...props, - open: true, - onClose: (event) => { - if (typeof props.onClose === 'function') { - props.onClose(event); - } - setModal({ - type: 'reset', - payload: { id } - }); - } - }, - ref, - container, - id - } - }); - - return { ref }; -} - -function showPopover( - props: PopoverPropTypes, - setModal: Dispatch>, - container?: ContainerElement -) { - checkContext(setModal); - const id = getRandomId(); - const ref = createRef(); - setModal?.({ - type: 'set', - payload: { - Component: Popover, - props: { - ...props, - - open: true, - onClose: (event) => { - if (typeof props.onClose === 'function') { - props.onClose(event); - } - setModal({ - type: 'reset', - payload: { id } - }); - } - }, - ref, - container, - id - } - }); - return { ref }; -} - -function showResponsivePopover( - props: ResponsivePopoverPropTypes, - setModal: Dispatch>, - container?: ContainerElement -) { - checkContext(setModal); - const id = getRandomId(); - const ref = createRef(); - setModal?.({ - type: 'set', - payload: { - Component: ResponsivePopover, - props: { - ...props, - open: true, - onClose: (event) => { - if (typeof props.onClose === 'function') { - props.onClose(event); - } - setModal({ - type: 'reset', - payload: { id } - }); - } - }, - ref, - container, - id - } - }); - return { ref }; -} - -function showMenu( - props: MenuPropTypes, - setModal: Dispatch>, - container?: ContainerElement -) { - checkContext(setModal); - const id = getRandomId(); - const ref = createRef(); - setModal?.({ - type: 'set', - payload: { - Component: Menu, - props: { - ...props, - open: true, - onClose: (event) => { - if (typeof props.onClose === 'function') { - props.onClose(event); - } - setModal({ - type: 'reset', - payload: { id } - }); - } - }, - ref, - container, - id - } - }); - return { ref }; -} - -function showMessageBox( - props: MessageBoxPropTypes, - setModal: Dispatch>, - container?: ContainerElement -) { - checkContext(setModal); + container?: Element | DocumentFragment +): ClosableModalReturnType { const id = getRandomId(); const ref = createRef(); - setModal?.({ - type: 'set', - payload: { - // @ts-expect-error: props type safety is covered by the `props` property - Component: MessageBox, - props: { - ...props, - open: true, - onClose: (event) => { - if (typeof props.onClose === 'function') { - props.onClose(event); - } - setModal({ - type: 'reset', - payload: { id } - }); - } - }, - ref, - container, - id - } - }); - return { ref }; -} - -function showToast( - props: ToastPropTypes, - setModal: Dispatch>, - container?: ContainerElement -) { - const ref = createRef() as MutableRefObject; - checkContext(setModal); - const id = getRandomId(); - setModal?.({ - type: 'set', - payload: { - Component: Toast, - props: { - ...props, - open: true, - onClose: (event) => { - if (typeof props.onClose === 'function') { - props.onClose(event); - } - setModal({ - type: 'reset', - payload: { id } - }); + ModalStore.addModal({ + Component: Dialog, + props: { + ...props, + open: true, + onClose: (event) => { + if (typeof props.onClose === 'function') { + props.onClose(event); } - }, - container, - id - } + ModalStore.removeModal(id); + } + }, + ref, + container, + id }); - return { ref }; -} - -function showDialogFn( - props: DialogPropTypes, - container?: ContainerElement -): ClosableModalReturnType { - const setModal = window['@ui5/webcomponents-react']?.setModal; - - const { ref } = showDialog(props, setModal, container); return { ref, @@ -263,36 +63,29 @@ function showDialogFn( }; } -function useShowDialogHook(): CloseableModalHookReturnType< - DialogPropTypes, - DialogDomRef, - ContainerElement -> { - const { setModal } = useModalsContext(); - - return useCallback( - (props, container) => { - const { ref } = showDialog(props, setModal, container); - - return { - ref, - close: () => { - if (ref.current) { - ref.current.open = false; - } - } - }; - }, - [setModal] - ); -} - -function showPopoverFn( +function showPopoverFn( props: PopoverPropTypes, - container?: ContainerElement + container?: Element | DocumentFragment ): ClosableModalReturnType { - const setModal = window['@ui5/webcomponents-react']?.setModal; - const { ref } = showPopover(props, setModal, container); + const id = getRandomId(); + const ref = createRef(); + ModalStore.addModal({ + Component: Popover, + props: { + ...props, + + open: true, + onClose: (event) => { + if (typeof props.onClose === 'function') { + props.onClose(event); + } + ModalStore.removeModal(id); + } + }, + ref, + container, + id + }); return { ref, @@ -304,35 +97,28 @@ function showPopoverFn( }; } -function useShowPopoverHook(): CloseableModalHookReturnType< - PopoverPropTypes, - PopoverDomRef, - ContainerElement -> { - const { setModal } = useModalsContext(); - return useCallback( - (props, container) => { - const { ref } = showPopover(props, setModal, container); - - return { - ref, - close: () => { - if (ref.current) { - ref.current.open = false; - } - } - }; - }, - [setModal] - ); -} - -function showResponsivePopoverFn( +function showResponsivePopoverFn( props: ResponsivePopoverPropTypes, - container?: ContainerElement + container?: Element | DocumentFragment ): ClosableModalReturnType { - const setModal = window['@ui5/webcomponents-react']?.setModal; - const { ref } = showResponsivePopover(props, setModal, container); + const id = getRandomId(); + const ref = createRef(); + ModalStore.addModal({ + Component: ResponsivePopover, + props: { + ...props, + open: true, + onClose: (event) => { + if (typeof props.onClose === 'function') { + props.onClose(event); + } + ModalStore.removeModal(id); + } + }, + ref, + container, + id + }); return { ref, @@ -344,35 +130,25 @@ function showResponsivePopoverFn( }; } -function useShowResponsivePopoverHook(): CloseableModalHookReturnType< - ResponsivePopoverPropTypes, - ResponsivePopoverDomRef, - ContainerElement -> { - const { setModal } = useModalsContext(); - return useCallback( - (props, container) => { - const { ref } = showResponsivePopover(props, setModal, container); - - return { - ref, - close: () => { - if (ref.current) { - ref.current.open = false; - } +function showMenuFn(props: MenuPropTypes, container?: Element | DocumentFragment): ClosableModalReturnType { + const id = getRandomId(); + const ref = createRef(); + ModalStore.addModal({ + Component: Menu, + props: { + ...props, + open: true, + onClose: (event) => { + if (typeof props.onClose === 'function') { + props.onClose(event); } - }; + ModalStore.removeModal(id); + } }, - [setModal] - ); -} - -function showMenuFn( - props: MenuPropTypes, - container?: ContainerElement -): ClosableModalReturnType { - const setModal = window['@ui5/webcomponents-react']?.setModal; - const { ref } = showMenu(props, setModal, container); + ref, + container, + id + }); return { ref, @@ -384,35 +160,29 @@ function showMenuFn( }; } -function useShowMenuHook(): CloseableModalHookReturnType< - MenuPropTypes, - MenuDomRef, - ContainerElement -> { - const { setModal } = useModalsContext(); - return useCallback( - (props, container) => { - const { ref } = showMenu(props, setModal, container); - - return { - ref, - close: () => { - if (ref.current) { - ref.current.open = false; - } - } - }; - }, - [setModal] - ); -} - -function showMessageBoxFn( +function showMessageBoxFn( props: MessageBoxPropTypes, - container?: ContainerElement + container?: Element | DocumentFragment ): ClosableModalReturnType { - const setModal = window['@ui5/webcomponents-react']?.setModal; - const { ref } = showMessageBox(props, setModal, container); + const id = getRandomId(); + const ref = createRef(); + ModalStore.addModal({ + // @ts-expect-error: props type safety is covered by the `props` property + Component: MessageBox, + props: { + ...props, + open: true, + onClose: (event) => { + if (typeof props.onClose === 'function') { + props.onClose(event); + } + ModalStore.removeModal(id); + } + }, + ref, + container, + id + }); return { ref, @@ -424,80 +194,64 @@ function showMessageBoxFn( }; } -function useShowMessageBox(): CloseableModalHookReturnType< - MessageBoxPropTypes, - DialogDomRef, - ContainerElement -> { - const { setModal } = useModalsContext(); - return useCallback( - (props, container) => { - const { ref } = showMessageBox(props, setModal, container); - - return { - ref, - close: () => { - if (ref.current) { - ref.current.open = false; - } +function showToastFn(props: ToastPropTypes, container?: Element | DocumentFragment): ModalReturnType { + const ref = createRef(); + const id = getRandomId(); + ModalStore.addModal({ + Component: Toast, + props: { + ...props, + open: true, + onClose: (event) => { + if (typeof props.onClose === 'function') { + props.onClose(event); } - }; + ModalStore.removeModal(id); + } }, - [setModal] - ); -} - -function showToastFn( - props: ToastPropTypes, - container?: ContainerElement -): ModalReturnType { - const setModal = window['@ui5/webcomponents-react']?.setModal; - const { ref } = showToast(props, setModal, container); + ref, + container, + id + }); return { ref }; } -function useShowToastHook(): ModalHookReturnType { - const { setModal } = useModalsContext(); - - return useCallback( - (props: ToastPropTypes, container?) => { - const { ref } = showToast(props, setModal, container); - return { - ref - }; - }, - [setModal] - ); -} - /** * Utility class for opening modals in an imperative way. * * These static helper methods might be useful for showing e.g. Toasts or MessageBoxes after successful or failed * network calls. * + * **In order to use these helpers, please make sure to render the `Modals` component somewhere in your application tree.** + * * @since 0.22.2 */ -export const Modals = { - showDialog: showDialogFn, - useShowDialog: useShowDialogHook, - showPopover: showPopoverFn, - useShowPopover: useShowPopoverHook, - showResponsivePopover: showResponsivePopoverFn, - useShowResponsivePopover: useShowResponsivePopoverHook, - /** - * @since 1.8.0 - */ - showMenu: showMenuFn, - /** - * @since 1.8.0 - */ - useShowMenu: useShowMenuHook, - showMessageBox: showMessageBoxFn, - useShowMessageBox: useShowMessageBox, - showToast: showToastFn, - useShowToast: useShowToastHook -}; +export function Modals() { + const modals = useSyncExternalStore(ModalStore.subscribe, ModalStore.getSnapshot, ModalStore.getServerSnapshot); + + return ( + <> + {modals.map((modal) => { + if (modal?.Component) { + return createPortal( + // @ts-expect-error: ref is supported by all supported modals + , + modal.container ?? document.body + ); + } + })} + > + ); +} + +Modals.displayName = 'Modals'; + +Modals.showDialog = showDialogFn; +Modals.showPopover = showPopoverFn; +Modals.showResponsivePopover = showResponsivePopoverFn; +Modals.showMenu = showMenuFn; +Modals.showMessageBox = showMessageBoxFn; +Modals.showToast = showToastFn; diff --git a/packages/main/src/components/ThemeProvider/ThemeProvider.cy.tsx b/packages/main/src/components/ThemeProvider/ThemeProvider.cy.tsx index b25ff670724..cb1ed102027 100644 --- a/packages/main/src/components/ThemeProvider/ThemeProvider.cy.tsx +++ b/packages/main/src/components/ThemeProvider/ThemeProvider.cy.tsx @@ -19,8 +19,6 @@ describe('ThemeProvider', () => { cy.get('html').should('have.attr', 'data-sap-theme', 'sap_horizon'); cy.findByText('Change Theme').click(); cy.get('html').should('have.attr', 'data-sap-theme', 'sap_horizon_dark'); - - cy.window().its('@ui5/webcomponents-react').should('not.be.empty'); }); it('injects css via JS', () => { diff --git a/packages/main/src/components/ThemeProvider/index.tsx b/packages/main/src/components/ThemeProvider/index.tsx index d9818637c5c..affbb3aa971 100644 --- a/packages/main/src/components/ThemeProvider/index.tsx +++ b/packages/main/src/components/ThemeProvider/index.tsx @@ -11,7 +11,6 @@ import { useStylesheet } from '@ui5/webcomponents-react-base'; import type { FC, ReactNode } from 'react'; -import { ModalsProvider } from '../Modals/ModalsProvider.js'; import { styleData } from './ThemeProvider.css.js'; function ThemeProviderStyles() { @@ -22,7 +21,6 @@ function ThemeProviderStyles() { export interface ThemeProviderPropTypes { children: ReactNode; - withoutModalsProvider?: boolean; /** * You can set this flag to true in case you have imported our static CSS Bundle/s in your application. @@ -37,13 +35,10 @@ export interface ThemeProviderPropTypes { /** * In order to use `@ui5/webcomponents-react` you have to wrap your application's root component into the ThemeProvider. * - * __Note:__ Per default, the `ThemeProvider` adds another provider for the [Modals](https://sap.github.io/ui5-webcomponents-react/?path=/docs/user-feedback-modals--docs) API. - * If you don't use this, you can omit it by setting the prop `withoutModalsProvider` to `true`. (With v2.0, the `Modals` provider will be offered separately to reduce overhead) - * * __Note:__ Per default, the `ThemeProvider` injects the CSS for the components during runtime. If you have imported our static CSS bundle/s in your application, you can set the prop `staticCssInjected` to `true` to prevent this. */ const ThemeProvider: FC = (props: ThemeProviderPropTypes) => { - const { children, withoutModalsProvider = false, staticCssInjected = false } = props; + const { children, staticCssInjected = false } = props; useIsomorphicLayoutEffect(() => { document.documentElement.setAttribute('data-sap-theme', getTheme()); @@ -71,7 +66,7 @@ const ThemeProvider: FC = (props: ThemeProviderPropTypes return ( <> - {withoutModalsProvider ? children : {children}} + {children} > ); }; diff --git a/packages/main/src/internal/ModalStore.ts b/packages/main/src/internal/ModalStore.ts new file mode 100644 index 00000000000..7734c1c1a53 --- /dev/null +++ b/packages/main/src/internal/ModalStore.ts @@ -0,0 +1,54 @@ +import type { ComponentType, RefCallback, RefObject } from 'react'; + +const STORE_SYMBOL_LISTENERS = Symbol.for('@ui5/webcomponents-react/Modals/Listeners'); +const STORE_SYMBOL = Symbol.for('@ui5/webcomponents-react/Modals'); + +type IModal = { + Component: ComponentType; + props: Record; + ref: RefObject | RefCallback; + container?: Element | DocumentFragment; + id: string; +}; + +const initialStore: IModal[] = []; + +function getListeners(): Array<() => void> { + globalThis[STORE_SYMBOL_LISTENERS] ??= []; + return globalThis[STORE_SYMBOL_LISTENERS]; +} + +function emitChange() { + for (const listener of getListeners()) { + listener(); + } +} + +function getSnapshot(): IModal[] { + globalThis[STORE_SYMBOL] ??= initialStore; + return globalThis[STORE_SYMBOL]; +} + +function subscribe(listener: () => void) { + const listeners = getListeners(); + globalThis[STORE_SYMBOL_LISTENERS] = [...listeners, listener]; + return () => { + globalThis[STORE_SYMBOL_LISTENERS] = listeners.filter((l) => l !== listener); + }; +} + +export const ModalStore = { + subscribe, + getSnapshot, + getServerSnapshot: () => { + return initialStore; + }, + addModal(config: IModal) { + globalThis[STORE_SYMBOL] = [...getSnapshot(), config]; + emitChange(); + }, + removeModal(id: string) { + globalThis[STORE_SYMBOL] = getSnapshot().filter((modal) => modal.id !== id); + emitChange(); + } +}; diff --git a/packages/main/src/internal/ModalsContext.ts b/packages/main/src/internal/ModalsContext.ts deleted file mode 100644 index 390625a4c91..00000000000 --- a/packages/main/src/internal/ModalsContext.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ComponentType, ContextType, Dispatch, RefCallback, RefObject } from 'react'; -import { createContext, useContext } from 'react'; - -export interface UpdateModalStateAction { - type: 'set' | 'reset'; - payload?: ModalState | { id: string }; -} - -export interface ModalState { - Component: ComponentType; - props: Props; - ref: RefObject | RefCallback; - container: ContainerElement; - id: string; -} - -interface IModalsContext { - setModal?: Dispatch>; -} - -const ModalsContext = createContext, HTMLElement>>({ - setModal: null -}); - -export function getModalContext() { - if (!globalThis['@ui5/webcomponents-react']?.ModalsContext) { - globalThis['@ui5/webcomponents-react'] ??= {}; - globalThis['@ui5/webcomponents-react'].ModalsContext = ModalsContext; - } - - return globalThis['@ui5/webcomponents-react'].ModalsContext; -} - -export const useModalsContext = (): ContextType => { - return useContext(getModalContext()); -}; diff --git a/types.d.ts b/types.d.ts index 18711e3671d..42b6e3472b6 100644 --- a/types.d.ts +++ b/types.d.ts @@ -1,26 +1,3 @@ -import type { ComponentType, Context, Dispatch } from 'react'; - -interface UpdateModalStateAction { - type: 'set' | 'reset'; - payload?: ModalState | { id: string }; -} - -interface ModalState { - Component: ComponentType; - props: Record; - container: HTMLElement; - id: string; -} - -declare global { - interface Window { - ['@ui5/webcomponents-react']: { - ModalsContext?: Context; - setModal?: Dispatch; - }; - } -} - declare module '*.md' { const content: string; export default content;