Skip to main content

Dialogs

Dialogs in Oidis are Dialog components (a Bootstrap modal or offcanvas) opened and closed with Visible(). The DialogBinding class adds keyboard handling — Esc clicks the close button, Enter clicks the submit button.

Package: io-oidis-usercontrols

import { DialogBinding } from "@io-oidis-usercontrols/Io/Oidis/UserControls/Bindings/DialogBinding.js";
import { Dialog } from "@io-oidis-usercontrols/Io/Oidis/UserControls/Bootstrap/UserControls/Dialog.js";

The Dialog Component​

Dialog.Visible(true) shows the modal and Visible(false) hides it. The bound element must be a Bootstrap .modal (or .offcanvas) element — the component creates the bootstrap.Modal / bootstrap.Offcanvas instance on the first call:

<div class="modal fade" role="dialog" tabindex="-1" data-oidis-bind="${this.deleteDialog}">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
...
</div>
</div>
</div>

The Bootstrap modal is driven only once the component is loaded, and it opens or closes asynchronously (~100 ms later). Visible() without an argument returns the state tracked from the Bootstrap shown / hidden events, so it is also correct after the user closes the dialog with the backdrop or a data-bs-dismiss="modal" button.

DialogBinding​

Setup in resolver()​

public async resolver($instance : MyPanel) : Promise<void> {
new DialogBinding().Bind({
close : $instance.cancelButton,
holder: $instance.confirmDialog,
submit: $instance.confirmButton
});
}

Bind options (IDialogUserControls):

OptionTypeDescription
holderDialogThe dialog to manage
closeButton | Button[] (optional)Esc clicks the first button. A single Button also gets a click handler that hides the dialog
submitButton | Button[]Enter clicks the first button while the dialog is visible. The dialog stays open — you control when to close
An array in close gets no click handler

Only a single Button passed as close is wired to hide the dialog. When you pass an array, hide the dialog yourself — with data-bs-dismiss="modal" on the buttons or with your own setOnClick() handlers:

new DialogBinding().Bind({
close : [$instance.closeKill, $instance.cancelKill],
holder: $instance.killDialog,
submit: $instance.killConfirm
});
[$instance.closeKill, $instance.cancelKill].forEach(($button : Button) : void => {
$button.getEvents().setOnClick(() : void => {
$instance.killDialog.Visible(false);
});
});
Enter submits from any field

The Enter handler is registered on the window. While the dialog is visible, pressing Enter anywhere — including inside a <textarea> — clicks the submit button.

Keys act only while holder.Visible() is true

The window key handler checks holder.Visible() before it clicks anything. Dialog resets that state from its hidden.bs.modal (hidden.bs.offcanvas) listener, so once the user closes the dialog through a data-bs-dismiss="modal" button or the backdrop, Enter and Esc no longer click the buttons of the hidden dialog — no extra close handler is needed for that.

The shown / hidden listeners are attached when the first Visible(true) or Visible(false) call on the loaded component creates the Bootstrap instance. A dialog whose Visible() was never called with an argument — e.g. one opened only through data-bs-toggle="modal" markup — keeps reporting false, and DialogBinding ignores its keys. Open bound dialogs with Visible(true).

Opening the Dialog​

Open a dialog by setting it visible. Optionally pass data:

EventsBinding.SingleClick($item.deleteButton, async () : Promise<void> => {
$instance.confirmDialog.Data($item.Data());
$instance.confirmDialog.Visible(true);
});

Handling Confirmation​

The submit button's handler executes the action and closes the dialog:

EventsBinding.SingleClick($instance.confirmButton, async () : Promise<void> => {
try {
const record : IProduct = $instance.confirmDialog.Data();
$instance.confirmDialog.Visible(false);

await this.client.DeleteRecord(record.Id);
this.ShowMessage("Record deleted", ToastType.SUCCESS);
await $instance.table.ShowData();
} catch (ex) {
LogIt.Error("Delete failed", ex);
this.ShowMessage("Failed to delete", ToastType.ERROR);
}
});

Complete Example: Delete Confirmation​

Panel​

export class ProductPanel extends BasePanel {
public readonly table : ProductsTable;
public readonly deleteDialog : Dialog;
public readonly deleteName : Label;
public readonly deleteCancelButton : Button;
public readonly deleteConfirmButton : Button;

constructor() {
super();
this.table = new ProductsTable();
this.deleteDialog = new Dialog();
this.deleteName = new Label();
this.deleteCancelButton = new Button();
this.deleteConfirmButton = new Button();
}

protected innerHtml() : string {
return `
<div data-oidis-bind="${this.table}"></div>
<div class="modal fade" role="dialog" tabindex="-1" data-oidis-bind="${this.deleteDialog}">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body">
<p>Are you sure you want to delete <strong data-oidis-bind="${this.deleteName}"></strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-light" type="button" data-oidis-bind="${this.deleteCancelButton}">Cancel</button>
<button class="btn btn-danger" type="button" data-oidis-bind="${this.deleteConfirmButton}">Delete</button>
</div>
</div>
</div>
</div>`;
}
}

Controller​

public async resolver($instance : ProductPanel) : Promise<void> {
new DialogBinding().Bind({
close : $instance.deleteCancelButton,
holder: $instance.deleteDialog,
submit: $instance.deleteConfirmButton
});

EventsBinding.SingleClick($instance.deleteConfirmButton, async () : Promise<void> => {
try {
const record : IProduct = $instance.deleteDialog.Data();
$instance.deleteDialog.Visible(false);
await this.client.DeleteProduct(record.Id);
this.ShowMessage("Product deleted", ToastType.SUCCESS);
await $instance.table.ShowData();
} catch (ex) {
LogIt.Error("Delete failed", ex);
this.ShowMessage("Failed to delete product", ToastType.ERROR);
}
});
}

public async beforeLoad($instance : ProductPanel) : Promise<void> {
$instance.table.BindData(
async ($offset : number, $limit : number) : Promise<IModelListResult> => { ... },
async ($data : IProduct, $item : ProductsTableItem) : Promise<void> => { ... },
async ($item : ProductsTableItem) : Promise<void> => {
EventsBinding.SingleClick($item.deleteButton, async () : Promise<void> => {
$instance.deleteDialog.Data($item.Data());
$instance.deleteName.Text(escapeHtml($item.Data().Name));
$instance.deleteDialog.Visible(true);
});
}
);
}

Label.Text() writes HTML — escape user data (see GUI Pitfalls).

Edit Form Dialog​

For forms, a reusable Dialog subclass keeps the fields, filling and reading in one place and leaves only the save action to the controller:

export class ProductDialog extends Dialog {
public readonly title : Label;
public readonly nameField : TextField;
public readonly priceField : TextField;
public readonly save : Button;
public readonly cancel : Button;
private editedId : string;

constructor() {
super();
this.title = new Label();
this.nameField = new TextField();
this.priceField = new TextField();
this.save = new Button();
this.cancel = new Button();
this.editedId = "";
this.nameField.Required(true);
}

public Open($record : IProduct) : void {
this.editedId = ObjectValidator.IsEmptyOrNull($record) ? "" : $record.Id;
this.title.Text(ObjectValidator.IsEmptyOrNull($record) ? "New product" : "Edit product");
this.nameField.Value(ObjectValidator.IsEmptyOrNull($record) ? "" : $record.Name);
this.priceField.Value(ObjectValidator.IsEmptyOrNull($record) ? "" : String($record.Price));
this.nameField.ResetValidation();
this.Visible(true);
}

public Validate() : boolean {
return this.nameField.Validate();
}

public getInput() : IProduct {
return {Id: this.editedId, Name: this.nameField.Value(), Price: StringUtils.ToDouble(this.priceField.Value())};
}

protected innerCode() : string {
this.cancel.getEvents().setOnClick(() : void => {
this.Visible(false);
});
return super.innerCode();
}

protected innerHtml() : string {
return `
<div class="modal fade" role="dialog" tabindex="-1">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title" data-oidis-bind="${this.title}">New product</h5></div>
<div class="modal-body">
<form onsubmit="return false;">
<input class="form-control" type="text" data-oidis-bind="${this.nameField}">
<div class="invalid-feedback">Name is required</div>
<input class="form-control mt-3" type="text" data-oidis-bind="${this.priceField}">
</form>
</div>
<div class="modal-footer">
<button class="btn btn-light" type="button" data-oidis-bind="${this.cancel}">Cancel</button>
<button class="btn btn-primary" type="button" data-oidis-bind="${this.save}">Save</button>
</div>
</div>
</div>
</div>`;
}
}

When a Dialog subclass has its own innerHtml(), the component uses the first child element (the .modal) as its instance owner. In the panel it is bound to a plain placeholder: <div data-oidis-bind="${this.dialog}"></div>.

// Controller
EventsBinding.SingleClick($instance.addButton, async () : Promise<void> => {
$instance.dialog.Open(null);
});

EventsBinding.SingleClick($instance.dialog.save, async () : Promise<void> => {
if (!$instance.dialog.Validate()) {
return;
}
try {
await this.client.SaveProduct($instance.dialog.getInput());
$instance.dialog.Visible(false);
this.ShowMessage("Product saved", ToastType.SUCCESS);
await $instance.table.ShowData();
} catch (ex) {
LogIt.Error("Save failed", ex);
this.ShowMessage("Failed to save product", ToastType.ERROR);
}
});

Data Passing via .Data()​

The .Data() method on dialog components is the standard way to pass context between the trigger and the handler:

// Store data when opening
$instance.dialog.Data(record);

// Retrieve data in the handler
const record : IProduct = $instance.dialog.Data();

This avoids the need for separate state variables when the dialog always operates on a single record.

Dialogs and Page Changes​

Dialog keeps track of every open dialog (opened through Visible(true) or reported open by Bootstrap's shown event). Dialog.HideAll() hides them all, and BasePageLayoutController.onSuccess calls it at the start of every page change, before beforeTabLoad and processRequestPath.

Without it, a dialog that is still open when the page changes — its action navigated away, or a refused call (HTTP 401) redirected to the "not authorized" page — loses its element with the old content while the Bootstrap backdrop stays on <body>, and the new page cannot be clicked.

  • A dialog opened in beforeTabLoad or processRequestPath of the new page stays open; only the previous page's dialogs are closed.
  • A layout override of onSuccess must call super.onSuccess() before opening its own dialogs.
  • Call Dialog.HideAll() yourself when content is replaced outside the page lifecycle.

Key Rules​

  1. DialogBinding in resolver() — Always set up dialog bindings in the resolver lifecycle method
  2. Bind a .modal element — Dialog drives Bootstrap's modal; a plain <div> is not styled or positioned as a dialog
  3. Close handlers for arrays — Only a single close button is wired automatically
  4. Use .Data() for context — Store the relevant record on the dialog component
  5. Always try/catch — Dialog handlers follow the same error handling rules as all event handlers
  6. Refresh after action — Call ShowData() on the table to reflect changes

See also: Panels and Controllers | Events and Validation | User Controls | GUI Pitfalls