diff --git a/.changeset/eight-rocks-dream.md b/.changeset/eight-rocks-dream.md new file mode 100644 index 00000000000..780ccb6cb87 --- /dev/null +++ b/.changeset/eight-rocks-dream.md @@ -0,0 +1,14 @@ +--- +"@itwin/itwinui-react": minor +--- + +Added the ability to control `Dialog` imperatively using `show()` and `close()` methods from its instance. + +```tsx +const dialog = Dialog.useInstance(); + + + + + +``` diff --git a/apps/react-workshop/src/Dialog.stories.tsx b/apps/react-workshop/src/Dialog.stories.tsx index 20c36771095..b2f01f1e273 100644 --- a/apps/react-workshop/src/Dialog.stories.tsx +++ b/apps/react-workshop/src/Dialog.stories.tsx @@ -9,50 +9,25 @@ export default { title: 'Dialog', }; -export const Basic = () => { - const [isOpen, setIsOpen] = React.useState(false); - - const closeDialog = () => { - setIsOpen(false); - }; - - const onClose = () => { - console.log('onClose'); - closeDialog(); - }; - - const primaryButtonHandle = () => { - console.log('Primary button'); - closeDialog(); - }; +const lorem = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`; - const secondaryButtonHandle = () => { - console.log('Secondary button'); - closeDialog(); - }; +export const Basic = () => { + const dialog = Dialog.useInstance(); return ( <> - - + - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim - ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in - culpa qui officia deserunt mollit anim id est laborum. - + {lorem} - - + @@ -61,59 +36,29 @@ export const Basic = () => { }; export const Modal = () => { - const [isOpen, setIsOpen] = React.useState(false); - - const closeDialog = () => { - setIsOpen(false); - }; - - const onClose = () => { - console.log('onClose'); - closeDialog(); - }; - - const primaryButtonHandle = () => { - console.log('Primary button'); - closeDialog(); - }; - - const secondaryButtonHandle = () => { - console.log('Secondary button'); - closeDialog(); - }; + const dialog = Dialog.useInstance(); return ( <> - - console.log('onKeyDown')} /> + - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim - ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in - culpa qui officia deserunt mollit anim id est laborum. - + {lorem} - - + @@ -158,15 +103,7 @@ export const DraggableAndResizable = () => { > - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim - ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut - aliquip ex ea commodo consequat. Duis aute irure dolor in - reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla - pariatur. Excepteur sint occaecat cupidatat non proident, sunt in - culpa qui officia deserunt mollit anim id est laborum. - + {lorem} + + + + ``` + +2. By using the `isOpen` prop, often paired with `onClose` and a state variable. + + ```tsx + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + + + setIsDialogOpen(false)}> + + + ``` ### Dismissible diff --git a/examples/Dialog.draggable.jsx b/examples/Dialog.draggable.jsx index 9d651263a81..8f8ef3cbb71 100644 --- a/examples/Dialog.draggable.jsx +++ b/examples/Dialog.draggable.jsx @@ -3,7 +3,6 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -// import * as ReactDOM from 'react-dom'; import { Dialog, Button, @@ -12,24 +11,15 @@ import { } from '@itwin/itwinui-react'; export default () => { - const [isOpen, setIsOpen] = React.useState(false); + const dialog = Dialog.useInstance(); return ( <> - - setIsOpen(false)} - setFocus={false} - closeOnEsc - isDismissible - isDraggable - isResizable - portal - > + @@ -37,13 +27,10 @@ export default () => { - - + diff --git a/examples/Dialog.main.jsx b/examples/Dialog.main.jsx index 35f8bab9006..0420259aff4 100644 --- a/examples/Dialog.main.jsx +++ b/examples/Dialog.main.jsx @@ -3,26 +3,22 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -// import * as ReactDOM from 'react-dom'; import { Dialog, Button } from '@itwin/itwinui-react'; export default () => { - const [isOpen, setIsOpen] = React.useState(false); + const dialog = Dialog.useInstance(); return ( <> - setIsOpen(false)} - closeOnEsc + instance={dialog} closeOnExternalClick preventDocumentScroll trapFocus setFocus - isDismissible portal > @@ -35,13 +31,10 @@ export default () => { ask for a decision. - - + diff --git a/examples/Dialog.nondismissible.jsx b/examples/Dialog.nondismissible.jsx index 51e9389c653..16bba8901dc 100644 --- a/examples/Dialog.nondismissible.jsx +++ b/examples/Dialog.nondismissible.jsx @@ -3,24 +3,17 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -// import * as ReactDOM from 'react-dom'; import { Dialog, Button } from '@itwin/itwinui-react'; export default () => { - const [isOpen, setIsOpen] = React.useState(false); + const dialog = Dialog.useInstance(); return ( <> - - setIsOpen(false)} - setFocus={false} - isDismissible={false} - portal - > + @@ -29,13 +22,10 @@ export default () => { You can't undo this action. - - + diff --git a/examples/Dialog.placement.jsx b/examples/Dialog.placement.jsx index 9d4289ffb98..ee83ef860ee 100644 --- a/examples/Dialog.placement.jsx +++ b/examples/Dialog.placement.jsx @@ -3,26 +3,22 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -// import * as ReactDOM from 'react-dom'; import { Dialog, Button } from '@itwin/itwinui-react'; export default () => { - const [isOpen, setIsOpen] = React.useState(false); + const dialog = Dialog.useInstance(); return ( <> - setIsOpen(false)} - closeOnEsc + instance={dialog} closeOnExternalClick preventDocumentScroll trapFocus setFocus - isDismissible portal placement='top-left' > @@ -31,13 +27,10 @@ export default () => { - - + diff --git a/packages/itwinui-react/src/core/Dialog/Dialog.test.tsx b/packages/itwinui-react/src/core/Dialog/Dialog.test.tsx index 44022e16c1d..40b935dfd49 100644 --- a/packages/itwinui-react/src/core/Dialog/Dialog.test.tsx +++ b/packages/itwinui-react/src/core/Dialog/Dialog.test.tsx @@ -3,7 +3,7 @@ * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ import * as React from 'react'; -import { render, act } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import { Dialog } from './Dialog.js'; import { Button } from '../Buttons/Button.js'; import { userEvent } from '@testing-library/user-event'; @@ -188,3 +188,28 @@ it('should not stay in the DOM when isOpen=false', () => { dialogWrapper = container.querySelector('.iui-dialog-wrapper') as HTMLElement; expect(dialogWrapper).toBeFalsy(); }); + +it('should expose show() and close() methods', () => { + vi.useFakeTimers(); + + let dialog: ReturnType; + + const DialogTest = () => { + dialog = Dialog.useInstance(); + return ( + + Hello + + ); + }; + + render(); + + act(() => dialog.show()); + const dialogElement = screen.getByRole('dialog'); + expect(dialogElement).toBeVisible(); + + act(() => dialog.close()); + act(() => vi.runAllTimers()); + expect(dialogElement).not.toBeVisible(); +}); diff --git a/packages/itwinui-react/src/core/Dialog/Dialog.tsx b/packages/itwinui-react/src/core/Dialog/Dialog.tsx index e634bef3f90..bb236a23756 100644 --- a/packages/itwinui-react/src/core/Dialog/Dialog.tsx +++ b/packages/itwinui-react/src/core/Dialog/Dialog.tsx @@ -11,23 +11,49 @@ import { DialogContext } from './DialogContext.js'; import type { DialogContextProps } from './DialogContext.js'; import { DialogButtonBar } from './DialogButtonBar.js'; import { DialogMain } from './DialogMain.js'; -import { useMergedRefs, Box, Portal } from '../../utils/index.js'; +import { + Box, + Portal, + useControlledState, + useInstance, + useMergedRefs, + useSynchronizeInstance, +} from '../../utils/index.js'; import type { PolymorphicForwardRefComponent } from '../../utils/index.js'; import { Transition } from 'react-transition-group'; +// ---------------------------------------------------------------------------- + +type DialogInstance = { + /** Call this function to show (open) the dialog. */ + show: () => void; + /** Call this function to hide (close) the dialog. */ + close: () => void; +}; + type DialogProps = { /** * Dialog content. */ children: React.ReactNode; -} & Omit; + /** + * Pass an instance created by `useInstance` to control the dialog imperatively. + * + * @example + * const dialog = Dialog.useInstance(); + * + */ + instance?: DialogInstance; +} & Omit; -const DialogComponent = React.forwardRef((props, ref) => { +// ---------------------------------------------------------------------------- + +const DialogComponent = React.forwardRef((props, forwardedRef) => { const { trapFocus = false, setFocus = false, preventDocumentScroll = false, - isOpen = false, + isOpen: isOpenProp, isDismissible = true, closeOnEsc = true, closeOnExternalClick = false, @@ -38,9 +64,22 @@ const DialogComponent = React.forwardRef((props, ref) => { placement, className, portal = false, + instance: instanceProp, ...rest } = props; + const [isOpen, setIsOpen] = useControlledState(false, isOpenProp); + useSynchronizeInstance( + instanceProp, + React.useMemo( + () => ({ + show: () => setIsOpen(true), + close: () => setIsOpen(false), + }), + [setIsOpen], + ), + ); + const dialogRootRef = React.useRef(null); return ( @@ -48,6 +87,7 @@ const DialogComponent = React.forwardRef((props, ref) => { { @@ -75,6 +115,8 @@ const DialogComponent = React.forwardRef((props, ref) => { ); }) as PolymorphicForwardRefComponent<'div', DialogProps>; +// ---------------------------------------------------------------------------- + /** * Dialog component. * @example @@ -103,4 +145,5 @@ export const Dialog = Object.assign(DialogComponent, { TitleBar: DialogTitleBar, Content: DialogContent, ButtonBar: DialogButtonBar, + useInstance: useInstance as () => DialogInstance, }); diff --git a/packages/itwinui-react/src/core/Dialog/DialogBackdrop.tsx b/packages/itwinui-react/src/core/Dialog/DialogBackdrop.tsx index 3e63ceef9d0..8f0fdefdb7f 100644 --- a/packages/itwinui-react/src/core/Dialog/DialogBackdrop.tsx +++ b/packages/itwinui-react/src/core/Dialog/DialogBackdrop.tsx @@ -46,8 +46,9 @@ export const DialogBackdrop = React.forwardRef((props, ref) => { if (event.target !== backdropRef.current) { return; } - if (isDismissible && closeOnExternalClick && onClose) { - onClose(event); + if (isDismissible && closeOnExternalClick) { + dialogContext.setIsOpen?.(false); + onClose?.(event); } onMouseDown?.(event); }; diff --git a/packages/itwinui-react/src/core/Dialog/DialogContext.tsx b/packages/itwinui-react/src/core/Dialog/DialogContext.tsx index 336dce506d8..4842fe7db43 100644 --- a/packages/itwinui-react/src/core/Dialog/DialogContext.tsx +++ b/packages/itwinui-react/src/core/Dialog/DialogContext.tsx @@ -15,6 +15,8 @@ export type DialogContextProps = { * @default false */ isOpen?: boolean; + /** @private */ + setIsOpen?: (isOpen: boolean) => void; /** * Handler that is called when dialog is closed. */ @@ -84,7 +86,7 @@ export type DialogContextProps = { /** * Dialog root ref. For internal use. */ - dialogRootRef?: React.RefObject; + dialogRootRef?: React.RefObject; /** * Determines the positioning of Dialog on page. */ diff --git a/packages/itwinui-react/src/core/Dialog/DialogMain.tsx b/packages/itwinui-react/src/core/Dialog/DialogMain.tsx index 95d19e86a66..2a3b5ebae36 100644 --- a/packages/itwinui-react/src/core/Dialog/DialogMain.tsx +++ b/packages/itwinui-react/src/core/Dialog/DialogMain.tsx @@ -30,7 +30,10 @@ export type DialogMainProps = { * Content of the dialog. */ children: React.ReactNode; -} & Omit; +} & Omit< + DialogContextProps, + 'closeOnExternalClick' | 'dialogRootRef' | 'setIsOpen' +>; /** * Dialog component which can wrap any content. @@ -115,8 +118,9 @@ export const DialogMain = React.forwardRef((props, ref) => { } // Prevents React from resetting its properties event.persist(); - if (isDismissible && closeOnEsc && event.key === 'Escape' && onClose) { - onClose(event); + if (isDismissible && closeOnEsc && event.key === 'Escape') { + dialogContext.setIsOpen?.(false); + onClose?.(event); } onKeyDown?.(event); }; diff --git a/packages/itwinui-react/src/core/Dialog/DialogTitleBar.tsx b/packages/itwinui-react/src/core/Dialog/DialogTitleBar.tsx index db2958509cb..019f89f93c8 100644 --- a/packages/itwinui-react/src/core/Dialog/DialogTitleBar.tsx +++ b/packages/itwinui-react/src/core/Dialog/DialogTitleBar.tsx @@ -74,7 +74,10 @@ export const DialogTitleBar = Object.assign( { + dialogContext.setIsOpen?.(false); + onClose?.(event); + }} aria-label='Close' data-iui-shift='right' > diff --git a/packages/itwinui-react/src/utils/hooks/index.ts b/packages/itwinui-react/src/utils/hooks/index.ts index c60c0d923d6..d10532a7f62 100644 --- a/packages/itwinui-react/src/utils/hooks/index.ts +++ b/packages/itwinui-react/src/utils/hooks/index.ts @@ -17,3 +17,4 @@ export * from './useIsClient.js'; export * from './useId.js'; export * from './useControlledState.js'; export * from './useSyncExternalStore.js'; +export * from './useInstance.js'; diff --git a/packages/itwinui-react/src/utils/hooks/useInstance.ts b/packages/itwinui-react/src/utils/hooks/useInstance.ts new file mode 100644 index 00000000000..b371d7d9c32 --- /dev/null +++ b/packages/itwinui-react/src/utils/hooks/useInstance.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +import { useSyncExternalStore } from './useSyncExternalStore.js'; + +class Instance {} + +export const useInstance = () => React.useMemo(() => new Instance(), []); + +/** + * Synchronizes the instance with the provided properties. + * + * @param instance Instance created by `useInstance`. + * @param properties Memoized object containing properties to be synchronized. + * + * @example + * const instance = useInstance(); + * + * const properties = React.useMemo(() => ({ + * show: () => console.log('show'), + * }), []); + * + * useSynchronizeInstance(instance, properties); + * + * instance.show(); // logs 'show' + */ +export const useSynchronizeInstance = (instance: T, properties: T) => { + const synchronize = React.useCallback(() => { + if (!(instance instanceof Instance)) { + return () => {}; + } + + Object.assign(instance, properties); + return () => { + for (const key in properties) { + delete instance[key]; + } + }; + }, [instance, properties]); + + return useSyncExternalStore( + synchronize, + () => instance, + () => instance, + ); +}; diff --git a/testing/e2e/app/routes/Dialog/route.tsx b/testing/e2e/app/routes/Dialog/route.tsx new file mode 100644 index 00000000000..0cecec62705 --- /dev/null +++ b/testing/e2e/app/routes/Dialog/route.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Dialog } from '@itwin/itwinui-react'; + +export default function DialogTest() { + const dialog = Dialog.useInstance(); + + React.useEffect(() => { + dialog.show(); + }, [dialog]); + + return ( + + Hello + + ); +} diff --git a/testing/e2e/app/routes/Dialog/spec.ts b/testing/e2e/app/routes/Dialog/spec.ts new file mode 100644 index 00000000000..63ec0f9dac8 --- /dev/null +++ b/testing/e2e/app/routes/Dialog/spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Dialog triggers', () => { + test('should close dialog when pressing Esc', async ({ page }) => { + await page.goto('/Dialog'); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + }); +});