diff --git a/.changeset/sharp-ladybugs-clap.md b/.changeset/sharp-ladybugs-clap.md new file mode 100644 index 00000000000..53aed58ebcd --- /dev/null +++ b/.changeset/sharp-ladybugs-clap.md @@ -0,0 +1,7 @@ +--- +"@siemens/ix-angular": patch +"@siemens/ix-react": patch +"@siemens/ix-vue": patch +--- + +Add dismissModal function to allow programmatic closing of **ix-modal**s. diff --git a/packages/angular-standalone-test-app/src/app/app.routes.ts b/packages/angular-standalone-test-app/src/app/app.routes.ts index 92c115c6cbb..9dd52db63e6 100644 --- a/packages/angular-standalone-test-app/src/app/app.routes.ts +++ b/packages/angular-standalone-test-app/src/app/app.routes.ts @@ -947,6 +947,11 @@ export const routes: Routes = [ (m) => m.default ), }, + { + path: 'modal-close', + loadComponent: () => + import('../preview-examples/modal-close').then((m) => m.default), + }, { path: 'modal-by-template', loadComponent: () => diff --git a/packages/angular-standalone-test-app/src/preview-examples/modal-close.ts b/packages/angular-standalone-test-app/src/preview-examples/modal-close.ts new file mode 100644 index 00000000000..ba77b2c80d0 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/modal-close.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { IxButton } from '@siemens/ix-angular/standalone'; + +import { ModalService } from '@siemens/ix-angular'; +import ModalByInstanceExample from './modal-by-instance-content'; + +@Component({ + standalone: true, + selector: 'app-example', + imports: [IxButton], + template: + 'Show modal (auto-dismiss)', +}) +export default class ModalClose { + constructor(private readonly modalService: ModalService) {} + + async openModal() { + const modalInstance = await this.modalService.open({ + content: ModalByInstanceExample, + data: 'Some data', + }); + setTimeout(() => { + this.modalService.close(modalInstance, 'closed after 5 seconds'); + }, 5000); + } +} diff --git a/packages/angular-test-app/src/app/app-routing.module.ts b/packages/angular-test-app/src/app/app-routing.module.ts index 49b4c9bda8e..b7e1bf5e3f8 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -162,6 +162,7 @@ import Message from '../preview-examples/message'; import MessageBar from '../preview-examples/message-bar'; import MessageBarRemoval from '../preview-examples/message-bar-removal'; import ModalByInstance from '../preview-examples/modal-by-instance'; +import ModalClose from '../preview-examples/modal-close'; import ModalByInstanceContent from '../preview-examples/modal-by-instance-content'; import ModalByTemplate from '../preview-examples/modal-by-template'; import ModalFormIxButtonSubmit from '../preview-examples/modal-form-ix-button-submit'; @@ -777,6 +778,10 @@ const routes: Routes = [ path: 'modal-by-instance', component: ModalByInstance, }, + { + path: 'modal-close', + component: ModalClose, + }, { path: 'modal-by-template', component: ModalByTemplate, diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 2ee1ed16b30..bd5b651a6fb 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -173,6 +173,7 @@ import MessageBarRemoval from '../preview-examples/message-bar-removal'; import ModalByInstance from '../preview-examples/modal-by-instance'; import ModalByInstanceContent from '../preview-examples/modal-by-instance-content'; import ModalByTemplate from '../preview-examples/modal-by-template'; +import ModalClose from '../preview-examples/modal-close'; import ModalFormIxButtonSubmit from '../preview-examples/modal-form-ix-button-submit'; import ModalSizes from '../preview-examples/modal-sizes'; import NumberInput from '../preview-examples/number-input'; @@ -400,6 +401,7 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; ModalByInstanceContent, ModalByInstance, ModalByTemplate, + ModalClose, ModalFormIxButtonSubmit, ModalSizes, PaginationAdvanced, diff --git a/packages/angular-test-app/src/preview-examples/modal-close.ts b/packages/angular-test-app/src/preview-examples/modal-close.ts new file mode 100644 index 00000000000..4c2199731ea --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/modal-close.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { ModalService } from '@siemens/ix-angular'; +import ModalByInstanceExample from './modal-by-instance-content'; + +@Component({ + standalone: false, + selector: 'app-example', + template: + 'Show modal (auto-dismiss)', +}) +export default class ModalClose { + constructor(private readonly modalService: ModalService) {} + + async openModal() { + const modalInstance = await this.modalService.open({ + content: ModalByInstanceExample, + data: 'Some data', + }); + setTimeout(() => { + this.modalService.close(modalInstance, 'closed after 5 seconds'); + }, 5000); + } +} diff --git a/packages/angular/common/src/providers/modal/modal.service.spec.ts b/packages/angular/common/src/providers/modal/modal.service.spec.ts index 560bb5b523b..dbe8a6914ac 100644 --- a/packages/angular/common/src/providers/modal/modal.service.spec.ts +++ b/packages/angular/common/src/providers/modal/modal.service.spec.ts @@ -103,3 +103,37 @@ test('should create modal by component typ', async () => { modalElement: { type: 'html-element' }, }); }); + +test('should close modal instance with reason and mark as closed', () => { + const closeModalMock = jest.fn(function ( + this: { closed?: boolean }, + reason?: any + ) { + if (typeof reason !== 'undefined') { + this.closed = true; + } + }); + const instance = { + htmlElement: { + closeModal: closeModalMock, + closed: false, + }, + }; + const modalService = new ModalService({} as any, {} as any, {} as any); + const reason = 'user-dismissed'; + modalService.close(instance, reason); + expect(closeModalMock).toHaveBeenCalledWith(reason); + expect(instance.htmlElement.closed).toBe(true); +}); + +test('should throw TypeError if instance cannot be closed', () => { + const instance = { + htmlElement: {}, + }; + const modalService = new ModalService({} as any, {} as any, {} as any); + + expect(() => modalService.close(instance)).toThrow(TypeError); + expect(() => modalService.close(instance)).toThrow( + 'Invalid modal instance: cannot close' + ); +}); diff --git a/packages/angular/common/src/providers/modal/modal.service.ts b/packages/angular/common/src/providers/modal/modal.service.ts index c129d7fbef9..fb315a93242 100644 --- a/packages/angular/common/src/providers/modal/modal.service.ts +++ b/packages/angular/common/src/providers/modal/modal.service.ts @@ -36,6 +36,17 @@ export class ModalService { private injector: Injector ) {} + public close( + instance: { htmlElement: any }, + reason?: TReason + ): void { + if (typeof instance?.htmlElement?.closeModal === 'function') { + instance.htmlElement.closeModal(reason); + } else { + throw new TypeError('Invalid modal instance: cannot close'); + } + } + public async open(config: ModalConfig) { const context: ModalContext = { close: null, diff --git a/packages/angular/src/providers/modal/modal.service.ts b/packages/angular/src/providers/modal/modal.service.ts index b7dc73eb9d1..6ba79396f92 100644 --- a/packages/angular/src/providers/modal/modal.service.ts +++ b/packages/angular/src/providers/modal/modal.service.ts @@ -41,4 +41,11 @@ export class ModalService extends BaseModalService { ): Promise> { return super.open(config); } + + public close( + instance: ModalInstance, + reason?: TReason + ): void { + super.close(instance, reason); + } } diff --git a/packages/angular/standalone/src/providers/modal.ts b/packages/angular/standalone/src/providers/modal.ts index 3e92b6270b0..2df8450e326 100644 --- a/packages/angular/standalone/src/providers/modal.ts +++ b/packages/angular/standalone/src/providers/modal.ts @@ -37,4 +37,11 @@ export class ModalService extends BaseModalService { defineCustomElement(); return super.open(config); } + + public close( + instance: ModalInstance, + reason?: TReason + ): void { + super.close(instance, reason); + } } diff --git a/packages/html-test-app/src/preview-examples/modal-close.html b/packages/html-test-app/src/preview-examples/modal-close.html new file mode 100644 index 00000000000..a15a597fb24 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/modal-close.html @@ -0,0 +1,96 @@ + + + + + + + + Modal example + + + Show modal (auto-dismiss) + + + + + + diff --git a/packages/react-test-app/src/main.tsx b/packages/react-test-app/src/main.tsx index 1f79c6c63df..67ddcdc3be0 100644 --- a/packages/react-test-app/src/main.tsx +++ b/packages/react-test-app/src/main.tsx @@ -161,6 +161,7 @@ import Message from './preview-examples/message'; import MessageBar from './preview-examples/message-bar'; import MessageBarRemoval from './preview-examples/message-bar-removal.tsx'; import Modal from './preview-examples/modal'; +import ModalClose from './preview-examples/modal-close.tsx'; import ModalFormIxButtonSubmit from './preview-examples/modal-form-ix-button-submit.tsx'; import ModalSizes from './preview-examples/modal-sizes'; import NumberInput from './preview-examples/number-input'; @@ -597,6 +598,7 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( /> } /> + } /> } diff --git a/packages/react-test-app/src/preview-examples/modal-close.tsx b/packages/react-test-app/src/preview-examples/modal-close.tsx new file mode 100644 index 00000000000..eb94dc301ac --- /dev/null +++ b/packages/react-test-app/src/preview-examples/modal-close.tsx @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { + IxButton, + IxModalContent, + IxModalFooter, + IxModalHeader, + Modal, + ModalRef, + showModal, + dismissModal, +} from '@siemens/ix-react'; +import { useRef } from 'react'; + +function CustomModal() { + const modalRef = useRef(null); + + const close = () => { + modalRef.current?.close('close payload!'); + }; + const dismiss = () => { + modalRef.current?.dismiss('dismiss payload'); + }; + + return ( + + dismiss()}> + Message headline + + Message text lorem ipsum + + dismiss()}> + Cancel + + close()}>OK + + + ); +} + +export default () => { + async function show() { + const modalInstance = await showModal({ + content: , + }); + setTimeout(() => { + dismissModal(modalInstance); + }, 5000); + } + + return ( + <> + Show modal (auto-dismiss) + + ); +}; diff --git a/packages/react/src/modal/index.ts b/packages/react/src/modal/index.ts index 6d2303aef71..a77fdc833f6 100644 --- a/packages/react/src/modal/index.ts +++ b/packages/react/src/modal/index.ts @@ -10,6 +10,9 @@ import { ModalConfig as IxModalConfig, showModal as _showModal, + dismissModal as _dismissModal, + closeModal as _closeModal, + ModalInstance as IxModalInstance, } from '@siemens/ix'; export * from './modal'; @@ -22,3 +25,18 @@ export async function showModal( ) { return _showModal(config); } + +export function dismissModal(modalInstance: IxModalInstance) { + if (modalInstance?.htmlElement) { + _dismissModal(modalInstance.htmlElement); + } +} + +export function closeModal( + modalInstance: IxModalInstance, + reason?: T +) { + if (modalInstance?.htmlElement) { + _closeModal(modalInstance.htmlElement, reason); + } +} diff --git a/packages/vue-test-app/src/Root.vue b/packages/vue-test-app/src/Root.vue index a1b83a40123..33a55166407 100644 --- a/packages/vue-test-app/src/Root.vue +++ b/packages/vue-test-app/src/Root.vue @@ -164,6 +164,7 @@ import MessageBarRemoval from './preview-examples/message-bar-removal.vue'; import Message from './preview-examples/message.vue'; import ModalSizes from './preview-examples/modal-sizes.vue'; import ModalExample from './preview-examples/modal.vue'; +import ModalClose from './preview-examples/modal-close.vue'; import NumberInputDisabled from './preview-examples/number-input-disabled.vue'; import NumberInputLabel from './preview-examples/number-input-label.vue'; import NumberInputReadOnly from './preview-examples/number-input-readonly.vue'; @@ -404,6 +405,7 @@ const routes: any = { '/preview/settings': Settings, '/preview/kpi': Kpi, '/preview/modal': ModalExample, + '/preview/modal-close': ModalClose, '/preview/modal-form-ix-button-submit': ModalFormIxButtonSubmit, '/preview/number-input': NumberInput, '/preview/number-input-disabled': NumberInputDisabled, diff --git a/packages/vue-test-app/src/preview-examples/modal-close.vue b/packages/vue-test-app/src/preview-examples/modal-close.vue new file mode 100644 index 00000000000..bdec6f3e466 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/modal-close.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/packages/vue/src/modal/index.ts b/packages/vue/src/modal/index.ts index 8dbf931be0d..18f5bf6511a 100644 --- a/packages/vue/src/modal/index.ts +++ b/packages/vue/src/modal/index.ts @@ -1,6 +1,9 @@ import { ModalConfig as IxModalConfig, showModal as _showModal, + dismissModal as _dismissModal, + closeModal as _closeModal, + ModalInstance as IxModalInstance, } from '@siemens/ix'; import { VNode } from 'vue'; @@ -18,3 +21,18 @@ export async function showModal( ) { return _showModal(config); } + +export function dismissModal(modalInstance: IxModalInstance) { + if (modalInstance?.htmlElement) { + _dismissModal(modalInstance.htmlElement); + } +} + +export function closeModal( + modalInstance: IxModalInstance, + reason?: T +) { + if (modalInstance?.htmlElement) { + _closeModal(modalInstance.htmlElement, reason); + } +}