GUI Pitfalls
Mistakes that compile (or almost compile) but break at runtime or open security holes. Each item is verified against the
io-oidis-gui and io-oidis-usercontrols sources.
Label.Text and Content Render HTML
GuiCommons.Content($value) writes the value through ElementManager.setInnerHtml(), i.e. element.innerHTML.
Label.Text(), DropDownList.Text() and DropDownListItem.Text() all delegate to Content(). Every value passed to them
is parsed as HTML.
A record name like <img src=x onerror="..."> shown with label.Text($data.Name) runs script in the browser of every user
who views it (stored XSS). Escape every value that comes from users or from the database:
export function escapeHtml($value : string) : string {
return String($value ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
$item.productName.Text(escapeHtml($data.Name));
The framework has no such helper — keep it in one shared utility module of your project. ValidationBinding.setOnlyQuerySafeAllowed() escapes only what
is typed into one input — it does not protect data written by other clients or APIs.
Static markup you build yourself (icons, badges) may still be passed as HTML — escape only the interpolated values:
label.Text(`<span class="badge text-bg-success">${escapeHtml($data.Status)}</span>`).
Button Has No Text()
The Bootstrap Button has no Text() method — set its label with Content():
$instance.saveButton.Content("Save"); // ✅
$instance.saveButton.Text("Save"); // ❌ compile error
Content() is a setter only (returns void). DropDownListItem extends Button and adds its own Text().
Tooltips Are Bootstrap Tooltips, Never a Native title
A bare title="…" shows the browser's native tooltip, which does not match the Bootstrap UI. Always mark the element for a
Bootstrap tooltip:
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="tooltip" title="Delete" aria-label="Delete"
data-oidis-bind="${this.remove}">×</button>
GuiCommons initializes new bootstrap.Tooltip(element) for every [data-bs-toggle="tooltip"] element in a component's
innerHtml() when the component DOM is created — including table rows and dialogs created later, because each of them is
its own component. Markup written afterwards through Label.Text() / Content() is not scanned; initialize those
elements yourself:
$item.state.Text(`<span class="badge text-bg-warning" data-bs-toggle="tooltip" title="${escapeHtml($hint)}">locked</span>`);
$item.state.InstanceOwner().querySelectorAll("[data-bs-toggle=\"tooltip\"]").forEach(($element : HTMLElement) : void => {
new bootstrap.Tooltip($element);
});
Keep aria-label on icon-only buttons for screen readers — it does not replace the tooltip.
Label.Text("") Keeps the Previous Value
Label.Text() stores the value through Property.String, which ignores an empty string, and re-renders the previous
text. Use " " to clear a label, or call Content(""). In reused table rows this shows as values leaking between records —
see Tables — Restore Pattern.
d-none in the Markup Is Never Removed by Visible()
Visible() keeps a state and toggles d-none on the bound element; called before the panel is rendered (in resolver())
it only stores the state, which binding applies by adding d-none to a hidden element. A d-none written into the
markup is never removed, so Visible(true) cannot show it. Leave the class out of innerHtml() and hide the element by
calling Visible(false) in resolver() (as MyProfilePanelController does with the color scheme card); fill and show it
from FetchData() or later, when the panel is in the page.
Member Names Clash with CORE Members
A panel, dialog or row is a subclass of GuiCommons, so its members share one namespace with the private members of the
framework base classes. Reusing such a name fails to compile (Class 'X' incorrectly extends base class … Types have separate declarations of a private property) or, for public/protected members, silently replaces framework state.
| Declared by | Members |
|---|---|
BaseObject | objectNamespace, objectClassName |
GuiCommons | id, instance, children, parent, events, loaded, registered, completed, isWrapper, visible, enabled, active, data, environment, httpManager, lazyLoad |
Table<T> | localization (protected), content, header, body, pagination, noData, progress, progressValue, items |
TableItem | text (public label), styleClasses (protected) |
Dialog | shown, btModal |
Use specific names instead: activeField, contentHolder, productName, statusLabel.
Page layouts assign every tab controller to a page property named after the tab id (or declaredBy). A tab with the id
events overwrites GuiCommons.events and the page fails with this.events.Subscribe is not a function. See
Pages and Routing — Tab IDs become properties.
Components Are Created in the Constructor
Declare UI members without an initializer and create them after super(). Nothing instantiates = undefined members
automatically:
export class ProductsPanel extends BasePanel {
public readonly table : ProductsTable;
public readonly addButton : Button;
constructor() {
super();
this.table = new ProductsTable();
this.addButton = new Button();
}
}
EventsBinding Has Only SingleClick
EventsBinding.Change, DoubleClick, Blur or Focus do not exist. Use the component events manager:
getEvents().setOnChange(), setOnDoubleClick(), setOnBlur(), setOnFocus() or setEvent("oninput", …). See
Events and Validation.
InvalidFeedback Needs a Feedback Element
TextField.InvalidFeedback($message) writes into the next sibling .invalid-feedback element, or the first one in the
parent. Without such an element in innerHtml() the message is silently dropped. ValidationBinding.setInvalidFeedback()
does not exist.
Input Value in Markup Is Overwritten
Before a component is rendered, TextField.Value() (and every getInputDOM() based accessor) works on a detached
virtual <input>. When the component is bound to its element, GuiCommons copies the virtual value over the
element value — a value="…" attribute written in innerHtml() is replaced by an empty string. Set the value through
the control, before or after rendering:
this.priceField = new TextField();
this.priceField.Value(String($product.price)); // ✅ survives rendering
// <input value="${$product.price}" data-oidis-bind="${this.priceField}" /> ❌ rendered empty
Only value is copied — the checked attribute of a checkbox written in markup is kept.
Controls for a Variable Number of Rows
GuiCommons binds data-oidis-bind elements only to controls stored in enumerable properties of the component
(BaseObject.getProperties() walks for…in). Controls kept only in an array or a Map stay unbound and their clicks do
nothing. For layouts driven by data (rows, matrix columns), store every created control in its own property as well:
export abstract class ViewPart extends GuiCommons {
private ownedCount : number = 0;
protected own<T extends GuiCommons>($control : T) : T {
(<any>this)["owned" + this.ownedCount++] = $control;
return $control;
}
}
// constructor: this.rows = $items.map(($item) => ({item: $item, edit: this.own(new Button())}));
// innerHtml(): rows.map(($row) => `<button type="button" data-oidis-bind="${$row.edit}">Edit</button>`)
Build such a part from the data in its constructor, bind the events, then holder.Clear(); holder.AddChild(part).
AddChild(), or the last row stays deadAddChild() ends with Register(), which subscribes only controls whose events manager already exists — it is
created by the first getEvents() call. Events bound after AddChild() get subscribed only by the next
AddChild() of a sibling, so every row of a loop works except the last one. Order every loop as: create the control →
getEvents().setOnClick(…) / EventsBinding.SingleClick(…) → holder.AddChild(control) → DOM-dependent setup
(Show(), CSS classes). A flaky click on the last item is this bug, not a double click.
BasePanelAddChild() of a plain GuiCommons marks only the added part as loaded — the controls nested in it never get
ON_LOAD / ON_COMPLETE. Simple controls (Button, TextField, Label) do not notice, but every control that sets
itself up from ON_COMPLETE breaks: DatePicker, DateTimePicker, TimePicker, YearCalendar, YearStrip and
KanbanBoard never initialize (a DatePicker shows an open, empty calendar), DropDownSearch does not close on an
outside click, DropDownMultiselect keeps its "select all" hidden. A BasePanel added by AddChild() is rendered by
Render(), which runs the lifecycle of all its nested controls:
export class TokenRow extends BasePanel { // ✅ nested DatePicker initializes
public readonly expire : DatePicker;
// ...
}
// export class TokenRow extends GuiCommons { // ❌ calendar never closes, nested ON_COMPLETE never fires
Parts in the static template of a panel or dialog are rendered the same way and are not affected. The behavior is
covered by the DynamicAddChildRuntimeTest runtime test of io-oidis-usercontrols.
AddChild() parses the child markup inside a template element chosen by getDOMTemplateType(): tbody for markup
starting with <tr, table for <tbody, otherwise span. Markup that the HTML parser drops in a span needs an
override — e.g. a table cell component added to an existing <tr>:
export class MatrixCell extends GuiCommons {
protected getDOMTemplateType() : string {
return "tr";
}
protected innerHtml() : string {
return `<td class="text-end">${text}</td>`;
}
}
Do not build native <select> elements for choices — use DropDownList (or a subclass styled as a form field) so every
list looks and behaves like the rest of the Bootstrap UI. See User Controls — DropDownList.
DropDownMultiselect Without a Selection Keeps Its Placeholder Text
DropDownMultiselect writes the toggle text (localization.emptySelection, fullSelection or the selected items) only
when an item is selected or unselected. After Clear() with no items, or when nothing is selected while filling, the
toggle keeps its initial text — the generated control id. After filling it, set the text yourself when nothing is
selected:
if (!$options.some(($option) : boolean => $selected.includes($option.value))) {
this.Text(this.localization.emptySelection);
}
With an empty item list the base logic would even report "all selected", because no item is unselected.
DialogBinding Closes Only a Single Close Button
new DialogBinding().Bind({close: [...]}) with an array registers no click handlers — only Esc clicks the first button.
Pass a single Button, or hide the dialog yourself. See Dialogs.
See also: Panels and Controllers | Tables | Dialogs | Troubleshooting