import { isFunction, type Constructable, IContainer, InstanceProvider, onResolve, resolve } from '@aurelia/kernel'; import { Controller, ICustomElementController, IEventTarget, INode, IPlatform, CustomElement, CustomElementDefinition, registerHostNode } from '@aurelia/runtime-html'; import { IDialogController, IDialogDom, DialogOpenResult, DialogCloseResult, DialogCancelError, DialogCloseError, } from './dialog-interfaces'; import { instanceRegistration } from './utilities-di'; import type { DialogDeactivationStatuses, IDialogComponent, IDialogLoadedSettings, } from './dialog-interfaces'; import { ErrorNames, createMappedError } from './errors'; /** * A controller object for a Dialog instance. */ export class DialogController implements IDialogController { private readonly p = resolve(IPlatform); private readonly ctn = resolve(IContainer); /** @internal */ private cmp!: IDialogComponent; /** @internal */ private _resolve!: (result: DialogCloseResult) => void; /** @internal */ private _reject!: (reason: unknown) => void; /** @internal */ private _closingPromise: Promise | undefined; /** * The settings used by this controller. */ public settings!: IDialogLoadedSettings; public readonly closed: Promise; /** * The dom structure created to support the dialog associated with this controller */ private dom!: IDialogDom; /** * The component controller associated with this dialog controller * * @internal */ private controller!: ICustomElementController; public constructor() { this.closed = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } /** @internal */ public activate(settings: IDialogLoadedSettings): Promise { const container = this.ctn.createChild(); const { model, template, rejectOnCancel, renderer, } = settings; const resolvedRenderer = isFunction(renderer) ? container.invoke(renderer) : renderer; const dialogTargetHost = settings.host as HTMLElement ?? this.p.document.body; const dom = this.dom = resolvedRenderer.render(dialogTargetHost, this, settings.options); const rootEventTarget = container.has(IEventTarget, true) ? container.get(IEventTarget) as Element : null; const contentHost = dom.contentHost; this.settings = settings; // application root host may be a different element with the dialog root host // example: // // // // when it's different, need to ensure delegate bindings work if (rootEventTarget == null || !rootEventTarget.contains(dialogTargetHost)) { container.register(instanceRegistration(IEventTarget, dialogTargetHost)); } container.register(instanceRegistration(IDialogDom, dom)); registerHostNode(container, contentHost, this.p); return new Promise(r => { const cmp = Object.assign(this.cmp = this.getOrCreateVm(container, settings, contentHost), { $dialog: this }); r(cmp.canActivate?.(model) ?? true); }) .then(canActivate => { if (canActivate !== true) { dom.dispose(); if (rejectOnCancel) { throw createDialogCancelError(null, ErrorNames.dialog_activation_rejected); } return DialogOpenResult.create(true, this); } const cmp = this.cmp; return onResolve(cmp.activate?.(model), () => { const ctrlr = this.controller = Controller.$el( container, cmp, contentHost, null, CustomElementDefinition.create( this.getDefinition(cmp) ?? { name: CustomElement.generateName(), template } ) ) as ICustomElementController; return onResolve(ctrlr.activate(ctrlr, null), () => { return onResolve(dom.show?.(), () => DialogOpenResult.create(false, this) ); }); }); }, e => { dom.dispose(); throw e; }); } /** @internal */ public deactivate(status: T, value?: unknown): Promise> { if (this._closingPromise) { return this._closingPromise as Promise>; } let deactivating = true; const { controller, dom, cmp, settings: { rejectOnCancel }} = this; const dialogResult = DialogCloseResult.create(status, value); const promise: Promise> = new Promise>(r => { r(onResolve( cmp.canDeactivate?.(dialogResult) ?? true, canDeactivate => { if (canDeactivate !== true) { // we are done, do not block consecutive calls deactivating = false; this._closingPromise = void 0; if (rejectOnCancel) { throw createDialogCancelError(null, ErrorNames.dialog_cancellation_rejected); } return DialogCloseResult.create('abort' as T); } return onResolve(cmp.deactivate?.(dialogResult), () => onResolve(dom.hide?.(), () => onResolve(controller.deactivate(controller, null), () => { dom.dispose(); if (!rejectOnCancel && status !== 'error') { this._resolve(dialogResult); } else { this._reject(createDialogCancelError(value, ErrorNames.dialog_cancelled_with_cancel_on_rejection_setting)); } return dialogResult; } ) ) ); } )); }).catch(reason => { this._closingPromise = void 0; throw reason; }); // when component canDeactivate is synchronous, and returns something other than true // then the below assignment will override // the assignment inside the callback without the deactivating variable check this._closingPromise = deactivating ? promise : void 0; return promise; } /** * Closes the dialog with a successful output. * * @param value - The returned success output. */ public ok(value?: unknown): Promise> { return this.deactivate('ok', value); } /** * Closes the dialog with a cancel output. * * @param value - The returned cancel output. */ public cancel(value?: unknown): Promise> { return this.deactivate('cancel', value); } /** * Closes the dialog with an error output. * * @param value - A reason for closing with an error. * @returns Promise An empty promise object. */ public error(value: unknown): Promise { const closeError = createDialogCloseError(value); return new Promise(r => r(onResolve( this.cmp.deactivate?.(DialogCloseResult.create('error', closeError)), () => onResolve( this.controller.deactivate(this.controller, null), () => { this.dom.dispose(); this._reject(closeError); } ) ))); } private getOrCreateVm(container: IContainer, settings: IDialogLoadedSettings, host: HTMLElement): IDialogComponent { const Component = settings.component; if (Component == null) { return new EmptyComponent(); } if (typeof Component === 'object') { return Component; } const p = this.p; container.registerResolver( p.HTMLElement, container.registerResolver( p.Element, container.registerResolver(INode, new InstanceProvider('ElementResolver', host)) ) ); return container.invoke(Component); } private getDefinition(component?: object | Constructable) { const Ctor = (isFunction(component) ? component : component?.constructor) as Constructable; return CustomElement.isType(Ctor) ? CustomElement.getDefinition(Ctor) : null; } } class EmptyComponent {} function createDialogCancelError(output: T | undefined, code: ErrorNames/* , msg: string */): DialogCancelError { const error = createMappedError(code) as DialogCancelError; error.wasCancelled = true; error.value = output; return error; } function createDialogCloseError(output: T): DialogCloseError { const error = createMappedError(ErrorNames.dialog_custom_error) as DialogCloseError; error.wasCancelled = false; error.value = output; return error; }