From 67a68ad530e4fcb53250350507673310f0ceb24c Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Mon, 8 Jul 2024 17:46:31 +0200 Subject: [PATCH 1/4] refactor(Modals): replace context with use-sync-external-store BREAKING CHANGE: **ThemeProvider**: the prop `withoutModalsProvider` has been removed. For more information, please refer to our [Migration Guide](https://sap.github.io/ui5-webcomponents-react/main/?path=/docs/migration-guide--docs). BREAKING CHANGE: the hooks `useShowDialog`, `useShowPopover`, `useShowResponsivePopover`, `useShowMenu`, `useShowMessageBox` and `useShowToast` have been removed. For more information, please refer to our [Migration Guide](https://sap.github.io/ui5-webcomponents-react/main/?path=/docs/migration-guide--docs). --- docs/MigrationGuide.mdx | 142 +++-- .../codemod/transforms/v2/codemodConfig.json | 3 + .../main/src/components/Modals/Modals.cy.tsx | 245 ++------ .../main/src/components/Modals/Modals.mdx | 49 -- .../src/components/Modals/Modals.stories.tsx | 169 +++--- .../src/components/Modals/ModalsProvider.tsx | 51 -- packages/main/src/components/Modals/index.tsx | 538 +++++------------- .../src/components/ThemeProvider/index.tsx | 9 +- packages/main/src/internal/ModalStore.ts | 54 ++ packages/main/src/internal/ModalsContext.ts | 36 -- 10 files changed, 439 insertions(+), 857 deletions(-) delete mode 100644 packages/main/src/components/Modals/ModalsProvider.tsx create mode 100644 packages/main/src/internal/ModalStore.ts delete mode 100644 packages/main/src/internal/ModalsContext.ts diff --git a/docs/MigrationGuide.mdx b/docs/MigrationGuide.mdx index 0a874df5be5..62807d47566 100644 --- a/docs/MigrationGuide.mdx +++ b/docs/MigrationGuide.mdx @@ -65,7 +65,7 @@ Most variables can be replaced by applying the corresponding CSS classes from th ### Common CSS substitute classes
- Show + Show | Removed Variable | Equivalent Common CSS Class | | ----------------------------------- | ----------------------------- | @@ -125,7 +125,7 @@ Most variables can be replaced by applying the corresponding CSS classes from th ### Removed variables without substitute
- Show + Show | Removed Variable | Property and Value | | --------------------- | ------------------------------ | @@ -181,9 +181,9 @@ The `DynamicPage` component has been replaced with the `ui5-dynamic-page` web co #### Replaced Props - `backgroundDesign` is not available anymore. To set the background of the page you can use standard CSS and the respective CSS variables instead: - - **List:** `var(--sapGroup_ContentBackground)` - - **Solid:** `var(--sapBackgroundColor)` - - **Transparent:** `transparent` +- **List:** `var(--sapGroup_ContentBackground)` +- **Solid:** `var(--sapBackgroundColor)` +- **Transparent:** `transparent` - `alwaysShowContentHeader` has been renamed to `headerPinned` - `headerCollapsed` has been renamed to `headerSnapped` - `headerContentPinnable` (default: `true`) has been replaced by `hidePinButton` (default: `false`) @@ -196,37 +196,37 @@ The `DynamicPage` component has been replaced with the `ui5-dynamic-page` web co - `onPinnedStateChange` has been replaced by `onPinButtonToggle`. - `onToggleHeaderContent` has been replaced by `onTitleToggle`. - ```jsx - // v1 - function DynamicPageComponent(props) { - const [pinned, setPinned] = useState(false); - const [expanded, setExpanded] = useState(true); - return ( - setPinned(pinned)} - onToggleHeaderContent={(visible) => { - setExpanded(visible); - }} - /> - ); - } +```jsx +// v1 +function DynamicPageComponent(props) { + const [pinned, setPinned] = useState(false); + const [expanded, setExpanded] = useState(true); + return ( + setPinned(pinned)} + onToggleHeaderContent={(visible) => { + setExpanded(visible); + }} + /> + ); +} - // v2 - function DynamicPageComponent(props) { - const [pinned, setPinned] = useState(false); - const [expanded, setExpanded] = useState(true); - return ( - setPinned(event.target.headerPinned)} - onTitleToggle={(event) => { - setExpanded(!event.target.headerSnapped); - }} - /> - ); - } - ``` +// v2 +function DynamicPageComponent(props) { + const [pinned, setPinned] = useState(false); + const [expanded, setExpanded] = useState(true); + return ( + setPinned(event.target.headerPinned)} + onTitleToggle={(event) => { + setExpanded(!event.target.headerSnapped); + }} + /> + ); +} +``` #### Removed Props @@ -250,16 +250,16 @@ Since the `ObjectPage` isn't compatible with the `DynamicPageTitle` web componen - `subHeader` has been renamed to `subheading` and is now a slot. - `header` has been renamed to `heading` and is now a `slot`. The `font-size` isn't automatically adjusted anymore, so to keep the intended design you can leverage the new `snappedHeading` prop and apply the corresponding CSS Variables yourself. (see example below) - Example: +Example: - ```jsx - Header Title} - snappedHeading={ - Snapped Header Title - } - /> - ``` +```jsx +Header Title} + snappedHeading={ + Snapped Header Title + } +/> +``` #### Removed Props @@ -269,24 +269,24 @@ Since the `ObjectPage` isn't compatible with the `DynamicPageTitle` web componen - `expandedContent` is now part of the `subheading` prop, so if you've rendered a `MessageStrip` below the `subHeader` for example, you can now render the subheading and additional content both in the same slot. - `snappedContent` is now part of the `snappedSubheading` prop, so if you've rendered a `MessageStrip` below the `subHeader` for example, you can now render the subheading and additional content both in the same slot. - Example for combined `subHeader` and `expanded/snappedContent` in `subheading`/`snappedSubheading`: - - ```jsx - - - Information (only visible if header content is expanded) - - } - snappedSubheading={ - <> - - Information (only visible if header content is collapsed (snapped)) - - } - /> - ``` +Example for combined `subHeader` and `expanded/snappedContent` in `subheading`/`snappedSubheading`: + +```jsx + + + Information (only visible if header content is expanded) + + } + snappedSubheading={ + <> + + Information (only visible if header content is collapsed (snapped)) + + } +/> +``` ### Form @@ -602,6 +602,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. @@ -610,6 +623,11 @@ The prop `titleText` is now required and the default value `true` has been remov The prop `titleText` is now required. +### 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 a 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 e54b5d27f62..505d5503495 100644 --- a/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json +++ b/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json @@ -473,6 +473,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 ( <> + } /> - }); - }} - > - Show Popover - + <> + + } /> + }); + }} + > + Show Popover + + ); cy.mount(); @@ -56,18 +60,21 @@ describe('Modals - static helpers', () => { it('showResponsivePopover', () => { const TestComp = () => { return ( - } /> - }); - }} - > - Show Popover - + <> + + } /> + }); + }} + > + Show Popover + + ); cy.mount(); @@ -82,17 +89,20 @@ describe('Modals - static helpers', () => { it('showMenu', () => { const TestComp = () => { return ( - + <> + + + ); cy.mount(); @@ -108,6 +118,7 @@ describe('Modals - static helpers', () => { const TestComp = () => { return ( <> + - + <> + +
+ +
+ ); }; 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 ( - - ); - }; - - const TestComponentClosable = ({ hookFn, modalProps }: PropTypes) => { - const hook = hookFn(); - - return ( - - ] - }); - }} - > - 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 ( -
- -
- ); - }; - - 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..add67434ea8 100644 --- a/packages/main/src/components/Modals/Modals.mdx +++ b/packages/main/src/components/Modals/Modals.mdx @@ -10,25 +10,6 @@ import * as ComponentStories from './Modals.stories';
-## Usage Notes - -**In order to use these helpers, please make sure that your application is wrapped in the `` component.** - -We are offering those helpers methods both as hooks and static methods: - -`Modals.useShowXZY` - -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. - -
- ## Dialog @@ -38,11 +19,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 +51,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 +83,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 +117,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 +149,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 +181,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..9ef934691de 100644 --- a/packages/main/src/components/Modals/Modals.stories.tsx +++ b/packages/main/src/components/Modals/Modals.stories.tsx @@ -4,126 +4,139 @@ 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 ( - } /> - }); - }} - > - Show Dialog - + <> + + } /> + }); + }} + > + Show Dialog + + ); } }; export const Popover = { render: () => { - const showPopover = Modals.useShowPopover(); return ( - + <> + + + ); } }; export const ResponsivePopover = { render: () => { - const showResponsivePopover = Modals.useShowResponsivePopover(); return ( - + <> + + + ); } }; export const Menu = { render: () => { - const showMenu = Modals.useShowMenu(); return ( - + <> + + + ); } }; export const MessageBox = { render: () => { - const showMessageBox = Modals.useShowMessageBox(); return ( - + <> + + + ); } }; export const Toast = { render: () => { - const showToast = Modals.useShowToast(); return ( - + <> + + + ); } }; 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..376b664b4e5 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 is mounted 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/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()); -}; From c30096c160662b96bb9e70a311b6d861ec344a63 Mon Sep 17 00:00:00 2001 From: Marcus Notheis Date: Mon, 8 Jul 2024 19:48:14 +0200 Subject: [PATCH 2/4] remove global types --- .../ThemeProvider/ThemeProvider.cy.tsx | 2 -- types.d.ts | 23 ------------------- 2 files changed, 25 deletions(-) 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/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; From 98009adfc135c8e269d7ea223380c12739f5cc25 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 9 Jul 2024 11:31:26 +0200 Subject: [PATCH 3/4] fix docs stories, revert formatting of MigrationGuide --- .storybook/preview.tsx | 5 +- docs/MigrationGuide.mdx | 124 +++++++++--------- .../main/src/components/Modals/Modals.mdx | 21 ++- .../src/components/Modals/Modals.stories.tsx | 6 - 4 files changed, 85 insertions(+), 71 deletions(-) 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 5cba1e09640..7002bd64a21 100644 --- a/docs/MigrationGuide.mdx +++ b/docs/MigrationGuide.mdx @@ -65,7 +65,7 @@ Most variables can be replaced by applying the corresponding CSS classes from th ### Common CSS substitute classes
- Show + Show | Removed Variable | Equivalent Common CSS Class | | ----------------------------------- | ----------------------------- | @@ -125,7 +125,7 @@ Most variables can be replaced by applying the corresponding CSS classes from th ### Removed variables without substitute
- Show + Show | Removed Variable | Property and Value | | --------------------- | ------------------------------ | @@ -181,9 +181,9 @@ The `DynamicPage` component has been replaced with the `ui5-dynamic-page` web co #### Replaced Props - `backgroundDesign` is not available anymore. To set the background of the page you can use standard CSS and the respective CSS variables instead: -- **List:** `var(--sapGroup_ContentBackground)` -- **Solid:** `var(--sapBackgroundColor)` -- **Transparent:** `transparent` + - **List:** `var(--sapGroup_ContentBackground)` + - **Solid:** `var(--sapBackgroundColor)` + - **Transparent:** `transparent` - `alwaysShowContentHeader` has been renamed to `headerPinned` - `headerCollapsed` has been renamed to `headerSnapped` - `headerContentPinnable` (default: `true`) has been replaced by `hidePinButton` (default: `false`) @@ -196,37 +196,37 @@ The `DynamicPage` component has been replaced with the `ui5-dynamic-page` web co - `onPinnedStateChange` has been replaced by `onPinButtonToggle`. - `onToggleHeaderContent` has been replaced by `onTitleToggle`. -```jsx -// v1 -function DynamicPageComponent(props) { - const [pinned, setPinned] = useState(false); - const [expanded, setExpanded] = useState(true); - return ( - setPinned(pinned)} - onToggleHeaderContent={(visible) => { - setExpanded(visible); - }} - /> - ); -} + ```jsx + // v1 + function DynamicPageComponent(props) { + const [pinned, setPinned] = useState(false); + const [expanded, setExpanded] = useState(true); + return ( + setPinned(pinned)} + onToggleHeaderContent={(visible) => { + setExpanded(visible); + }} + /> + ); + } -// v2 -function DynamicPageComponent(props) { - const [pinned, setPinned] = useState(false); - const [expanded, setExpanded] = useState(true); - return ( - setPinned(event.target.headerPinned)} - onTitleToggle={(event) => { - setExpanded(!event.target.headerSnapped); - }} - /> - ); -} -``` + // v2 + function DynamicPageComponent(props) { + const [pinned, setPinned] = useState(false); + const [expanded, setExpanded] = useState(true); + return ( + setPinned(event.target.headerPinned)} + onTitleToggle={(event) => { + setExpanded(!event.target.headerSnapped); + }} + /> + ); + } + ``` #### Removed Props @@ -250,16 +250,16 @@ Since the `ObjectPage` isn't compatible with the `DynamicPageTitle` web componen - `subHeader` has been renamed to `subheading` and is now a slot. - `header` has been renamed to `heading` and is now a `slot`. The `font-size` isn't automatically adjusted anymore, so to keep the intended design you can leverage the new `snappedHeading` prop and apply the corresponding CSS Variables yourself. (see example below) -Example: + Example: -```jsx -Header Title} - snappedHeading={ - Snapped Header Title - } -/> -``` + ```jsx + Header Title} + snappedHeading={ + Snapped Header Title + } + /> + ``` #### Removed Props @@ -269,24 +269,24 @@ Example: - `expandedContent` is now part of the `subheading` prop, so if you've rendered a `MessageStrip` below the `subHeader` for example, you can now render the subheading and additional content both in the same slot. - `snappedContent` is now part of the `snappedSubheading` prop, so if you've rendered a `MessageStrip` below the `subHeader` for example, you can now render the subheading and additional content both in the same slot. -Example for combined `subHeader` and `expanded/snappedContent` in `subheading`/`snappedSubheading`: - -```jsx - - - Information (only visible if header content is expanded) - - } - snappedSubheading={ - <> - - Information (only visible if header content is collapsed (snapped)) - - } -/> -``` + Example for combined `subHeader` and `expanded/snappedContent` in `subheading`/`snappedSubheading`: + + ```jsx + + + Information (only visible if header content is expanded) + + } + snappedSubheading={ + <> + + Information (only visible if header content is collapsed (snapped)) + + } + /> + ``` ### Form diff --git a/packages/main/src/components/Modals/Modals.mdx b/packages/main/src/components/Modals/Modals.mdx index add67434ea8..71027296e25 100644 --- a/packages/main/src/components/Modals/Modals.mdx +++ b/packages/main/src/components/Modals/Modals.mdx @@ -3,12 +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'; + + -
+## General Usage Information + +Only one `Modals` component (``) should be rendered for each application, otherwise multiple popovers or dialogs are displayed. + +Example for mounting the `Modals` component: + +```jsx +import { Modals, ThemeProvider } from '@ui5/webcomponents-react'; +... +const root = createRoot(document.getElementById("root")); +root.render( + + + + +); +``` ## Dialog diff --git a/packages/main/src/components/Modals/Modals.stories.tsx b/packages/main/src/components/Modals/Modals.stories.tsx index 9ef934691de..5b645b9fedb 100644 --- a/packages/main/src/components/Modals/Modals.stories.tsx +++ b/packages/main/src/components/Modals/Modals.stories.tsx @@ -14,7 +14,6 @@ export const Dialog: Story = { render: () => { return ( <> -