diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/modal/event-table.mdx b/packages/dnb-design-system-portal/src/docs/uilib/components/modal/event-table.mdx index 9f32d045fae..2a579a8e944 100644 --- a/packages/dnb-design-system-portal/src/docs/uilib/components/modal/event-table.mdx +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/modal/event-table.mdx @@ -1,5 +1,15 @@ | Events | Description | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `onOpen` / `on_open` | _(optional)_ This event gets triggered once the modal shows up. Returns the modal id: `{ id }`. | -| `onClose` / `on_close` | _(optional)_ this event gets triggered once the modal gets closed. Returns the modal id: `{ id, event, triggeredBy }`. | +| `onClose` / `on_close` | _(optional)_ this event gets triggered once the modal gets closed. Returns the modal id: `{ id, event, triggeredBy }`. More info about the `triggeredBy` down below. | | `onClosePrevent` / `on_close_prevent` | _(optional)_ this event gets triggered once the user tries to close the modal, but `prevent_close` is set to **true**. Returns a callback `close` you can call to trigger the close mechanism. More details below. Returns the modal id: `{ id, event, close: Method, triggeredBy }` | + +## `triggeredBy` + +The `triggeredBy` property is given when the `onClose` or the `onClosePrevent` event is triggered. It can contain one of the following values: + +- `button`: The close button that triggered the event. +- `handler`: The `close` handler given by the function (as the content/children). +- `keyboard`: The escape key that triggered the event. +- `overlay`: The overlay element that triggered the event. +- `unmount`: The unmount event that triggered the `openState` prop change. diff --git a/packages/dnb-eufemia/src/components/dialog/__tests__/Dialog.test.tsx b/packages/dnb-eufemia/src/components/dialog/__tests__/Dialog.test.tsx index 9406daa9255..26213b250dd 100644 --- a/packages/dnb-eufemia/src/components/dialog/__tests__/Dialog.test.tsx +++ b/packages/dnb-eufemia/src/components/dialog/__tests__/Dialog.test.tsx @@ -23,6 +23,23 @@ beforeEach(() => { window.__modalStack = undefined }) +const log = global.console.log +beforeEach(() => { + global.console.log = jest.fn((...args) => { + if ( + !String(args[1]).includes( + 'A Dialog or Drawer needs a h1 as its first element!' + ) + ) { + log(...args) + } + }) +}) +afterEach(() => { + global.console.log = log + jest.resetAllMocks() +}) + describe('Dialog', () => { it('will run bodyScrollLock with disableBodyScroll', () => { render( diff --git a/packages/dnb-eufemia/src/components/drawer/__tests__/Drawer.test.tsx b/packages/dnb-eufemia/src/components/drawer/__tests__/Drawer.test.tsx index 8cf17e3d895..e1014e9eb56 100644 --- a/packages/dnb-eufemia/src/components/drawer/__tests__/Drawer.test.tsx +++ b/packages/dnb-eufemia/src/components/drawer/__tests__/Drawer.test.tsx @@ -22,6 +22,23 @@ beforeEach(() => { window.__modalStack = undefined }) +const log = global.console.log +beforeEach(() => { + global.console.log = jest.fn((...args) => { + if ( + !String(args[1]).includes( + 'A Dialog or Drawer needs a h1 as its first element!' + ) + ) { + log(...args) + } + }) +}) +afterEach(() => { + global.console.log = log + jest.resetAllMocks() +}) + describe('Drawer', () => { it('will run bodyScrollLock with disableBodyScroll', () => { render( diff --git a/packages/dnb-eufemia/src/components/modal/Modal.tsx b/packages/dnb-eufemia/src/components/modal/Modal.tsx index 22204aaa191..c66ccffe383 100644 --- a/packages/dnb-eufemia/src/components/modal/Modal.tsx +++ b/packages/dnb-eufemia/src/components/modal/Modal.tsx @@ -75,6 +75,7 @@ class Modal extends React.PureComponent< _tryToOpenTimeout: NodeJS.Timeout activeElement: Element isInTransition: boolean + modalContentCloseRef: React.RefObject state = { hide: false, @@ -170,6 +171,7 @@ class Modal extends React.PureComponent< this._id = props.id || makeUniqueId('modal-') this._triggerRef = React.createRef() + this.modalContentCloseRef = React.createRef() this._onUnmount = [] } @@ -350,8 +352,12 @@ class Modal extends React.PureComponent< close = ( event: Event, - { ifIsLatest, triggeredBy = null } = { ifIsLatest: true } + { ifIsLatest, triggeredBy = 'handler' } = { + ifIsLatest: true, + } ) => { + this.modalContentCloseRef.current?.(event, { triggeredBy }) + const { prevent_close = false } = this.props if (isTrue(prevent_close)) { @@ -448,7 +454,6 @@ class Modal extends React.PureComponent< vertical_alignment = 'center', id, // eslint-disable-line - open_state, // eslint-disable-line open_delay, // eslint-disable-line omit_trigger_button = false, @@ -534,6 +539,7 @@ class Modal extends React.PureComponent< close={this.close} hide={hide} title={rest.title || fallbackTitle} + modalContentCloseRef={this.modalContentCloseRef} /> )} diff --git a/packages/dnb-eufemia/src/components/modal/ModalContent.tsx b/packages/dnb-eufemia/src/components/modal/ModalContent.tsx index f5b51962842..41ca6e53a7d 100644 --- a/packages/dnb-eufemia/src/components/modal/ModalContent.tsx +++ b/packages/dnb-eufemia/src/components/modal/ModalContent.tsx @@ -22,7 +22,11 @@ import { } from '../../shared/component-helper' import ModalContext from './ModalContext' import { IS_IOS, IS_SAFARI, IS_MAC, isAndroid } from '../../shared/helpers' -import { ModalContentProps } from './types' +import { + CloseHandlerParams, + ModalContentProps, + TriggeredBy, +} from './types' import { getListOfModalRoots, getModalRoot, @@ -34,8 +38,6 @@ import { Context } from '../../shared' import { ContextProps } from '../../shared/Context' interface ModalContentState { - triggeredBy: string - triggeredByEvent: Event color: string } @@ -53,7 +55,7 @@ export default class ModalContent extends React.PureComponent< ModalContentProps, ModalContentState > { - state = { triggeredBy: null, triggeredByEvent: null, color: null } + state = { color: null } _contentRef: React.RefObject _scrollRef: React.RefObject @@ -64,6 +66,9 @@ export default class ModalContent extends React.PureComponent< _androidFocusTimeout: NodeJS.Timeout _ii: InteractionInvalidation _iiLocal: InteractionInvalidation + _triggeredBy: TriggeredBy + _triggeredByEvent: React.SyntheticEvent + _isControlled = false static contextType = Context @@ -74,6 +79,9 @@ export default class ModalContent extends React.PureComponent< this._contentRef = this.props.content_ref || React.createRef() this._scrollRef = this.props.scroll_ref || React.createRef() this._overlayClickRef = React.createRef() + if (this.props.modalContentCloseRef) { + this.props.modalContentCloseRef.current = this.setModalContentState + } // NB: The ""._id" is used in the __modalStack as "last._id" this._id = props.id @@ -111,6 +119,8 @@ export default class ModalContent extends React.PureComponent< } else { this._lockTimeout = setTimeout(this.lockBody, timeoutDuration * 1.2) // a little over --modal-animation-duration } + + this.setIsControlled() } componentWillUnmount() { @@ -119,6 +129,29 @@ export default class ModalContent extends React.PureComponent< this.removeLocks() } + setIsControlled() { + const { open_state } = this.props + if (typeof open_state !== 'undefined' && open_state !== null) { + this._isControlled = true + } + } + + componentDidUpdate() { + this.setIsControlled() + } + + wasOpenedManually() { + if (this._triggeredBy) { + return true + } + + if (this._isControlled) { + return true + } + + return false + } + lockBody = () => { const modalRoots = getListOfModalRoots() const firstLevel = modalRoots[0] @@ -182,13 +215,14 @@ export default class ModalContent extends React.PureComponent< this.removeAndroidFocusHelper() - const id = this.props.id - const { triggeredBy, triggeredByEvent } = this.state - dispatchCustomElementEvent(this, 'on_close', { - id, - event: triggeredByEvent, - triggeredBy: triggeredBy || 'unmount', - }) + if (this.wasOpenedManually()) { + const id = this.props.id + dispatchCustomElementEvent(this, 'on_close', { + id, + event: this._triggeredByEvent, + triggeredBy: this._triggeredBy || 'unmount', + }) + } if (typeof document !== 'undefined') { document.removeEventListener('keydown', this.onKeyDownHandler) @@ -349,13 +383,26 @@ export default class ModalContent extends React.PureComponent< } } - closeModalContent(event, { triggeredBy, ...params }) { + setModalContentState = ( + event: React.SyntheticEvent, + { triggeredBy }: CloseHandlerParams + ) => { + this._triggeredBy = triggeredBy + this._triggeredByEvent = event + } + + closeModalContent( + event, + { + triggeredBy, + ...params + }: CloseHandlerParams & { ifIsLatest?: boolean } + ) { event?.persist?.() - this.setState({ triggeredBy, triggeredByEvent: event }, () => { - this.props.close(event, { - triggeredBy, - ...params, - }) + + this.props.close(event, { + triggeredBy, + ...params, }) } diff --git a/packages/dnb-eufemia/src/components/modal/ModalRoot.tsx b/packages/dnb-eufemia/src/components/modal/ModalRoot.tsx index 839afe6659d..c9782fc96c7 100644 --- a/packages/dnb-eufemia/src/components/modal/ModalRoot.tsx +++ b/packages/dnb-eufemia/src/components/modal/ModalRoot.tsx @@ -22,6 +22,9 @@ export interface ModalRootProps extends ModalContentProps { * The content which will appear when triggering the modal/drawer. */ children?: ReactChildType + + /** For internal use only */ + modalContentCloseRef?: React.RefObject } interface ModalRootState { diff --git a/packages/dnb-eufemia/src/components/modal/__tests__/Modal.test.tsx b/packages/dnb-eufemia/src/components/modal/__tests__/Modal.test.tsx index 0c4a154026c..77bdd4fad05 100644 --- a/packages/dnb-eufemia/src/components/modal/__tests__/Modal.test.tsx +++ b/packages/dnb-eufemia/src/components/modal/__tests__/Modal.test.tsx @@ -14,6 +14,7 @@ import DialogContent from '../../dialog/DialogContent' import Provider from '../../../shared/Provider' import * as helpers from '../../../shared/helpers' import userEvent from '@testing-library/user-event' +import ModalHeaderBar from '../parts/ModalHeaderBar' global.userAgent = jest.spyOn(navigator, 'userAgent', 'get') global.appVersion = jest.spyOn(navigator, 'appVersion', 'get') @@ -31,13 +32,29 @@ beforeAll(() => { }) beforeEach(() => { - global.console.log = jest.fn() document.body.removeAttribute('style') document.documentElement.removeAttribute('style') document.getElementById('dnb-modal-root')?.remove() window.__modalStack = undefined }) +const log = global.console.log +beforeEach(() => { + global.console.log = jest.fn((...args) => { + if ( + !String(args[1]).includes( + 'A Dialog or Drawer needs a h1 as its first element!' + ) + ) { + log(...args) + } + }) +}) +afterEach(() => { + global.console.log = log + jest.resetAllMocks() +}) + describe('Modal component', () => { it('should add its instance to the stack', () => { render( @@ -229,47 +246,26 @@ describe('Modal component', () => { ).toHaveAttribute('disabled') }) - it('has working open event and close event if "Esc" key gets pressed', () => { + it('has working open event and close event if "Esc" key gets pressed', async () => { let testTriggeredBy = null - const on_close = jest.fn( + + const onOpen = jest.fn() + const onClose = jest.fn( ({ triggeredBy }) => (testTriggeredBy = triggeredBy) ) - const on_open = jest.fn() - render() + + render() + fireEvent.click(document.querySelector('button')) - expect(on_open).toHaveBeenCalledTimes(1) - expect(on_open).toHaveBeenCalledWith({ + expect(onOpen).toHaveBeenCalledTimes(1) + expect(onOpen).toHaveBeenCalledWith({ id: 'modal_id', }) expect(testTriggeredBy).toBe(null) - fireEvent.click(document.querySelector('button')) - document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 })) - expect(on_close).toHaveBeenCalledTimes(1) - }) - - it('will close modal by using callback method', () => { - const on_close = jest.fn() - const on_open = jest.fn() - - render( - - {({ close }) => { - return