DomBuilder reference
DomBuilder (core/sp-dom-builder.js) is the only way elements get made on the
client. Everything else — templates, widgets, modals, whole app surfaces — is
built out of it.
import { DomBuilder } from "../core/sp-dom-builder.js";
const b = new DomBuilder();
Instantiate one per app and keep it as this.dom. It holds a small amount of
state (an injected <style> tag, a camelCase cache), so reusing one instance is
cheaper than making them per call.
The PHP DomBuilder mirrors this API on the server. Component markup should
read the same whichever side produced it.
Contents
- createElement — the core
- Element factories
- Tables
- Lists
- DOM manipulation
- Query API
- Form values
- Select helpers
- Conditional rendering
- Modals and forms
- Menus and navigation
- Data containers
- Cards and widgets
- Settings controls
- Rich text and selection
- Media previews
- Drag and drop
- View transitions
- CSS Typed OM
- Utilities
createElement — the core
Every other factory funnels through this one.
b.createElement(tag, {
id, classList = [], text = "", html = "", value = "", type = "",
required = false, elEvent = {}, attributes = {}, pseudo = {},
hover = null, children = [],
});
| Option | Notes |
|---|---|
tag | Any HTML tag name |
id | Sets el.id |
classList | Array of strings. Empty and whitespace entries are dropped |
text | Sets textContent. Takes precedence over html |
html | Sets innerHTML, only when text is empty |
value | Applied when the element has a value property and the value is non-empty |
type | For inputs. Invalid types are swallowed |
required | Adds the required attribute |
elEvent | { click: fn, change: fn } — added with addEventListener |
attributes | See below |
pseudo | { "::before": {...}, ":focus": {...} } |
hover | Style object applied on :hover |
children | Nodes or strings. Anything else is ignored |
The attributes object
Four kinds of value are handled differently:
attributes: {
// 1. style — object, camelCase properties
style: { borderTopLeftRadius: "8px", height: "30px", zIndex: 100 },
// 2. dataset — becomes data-* attributes
dataset: { template: "true", registerHeaderAction: "refreshSelect" },
// 3. boolean — present when true, removed when false
hidden: true,
// 4. anything else — setAttribute
placeholder: "Search...",
}
style uses the CSS Typed OM (attributeStyleMap) where available, falling
back to el.style[prop] when parsing fails. Numbers get px appended unless
the property is in the unitless list (zIndex, opacity, flexGrow,
flexShrink, order, lineHeight, fontWeight, tabSize, zoom). Empty,
null and undefined values are skipped rather than written.
dataset keys are camelCase and become kebab-case attributes.
registerHeaderAction → data-register-header-action. This is how every
template attribute in the data managers is written.
Pseudo-elements and hover
b.createDiv({
text: "Hover me",
hover: { background: "var(--bg-layer-4)", transform: "translateY(-1px)" },
pseudo: {
"::after": { content: '"→"', marginLeft: "6px" },
":focus-visible": { outline: "2px solid var(--accent)" },
},
});
These cannot be expressed inline, so the builder generates a unique class
(sp-auto-xxxxxxx), writes a rule for it, and appends the rule to a shared
<style> tag in <head>.
Every element with
pseudoorhoveradds rules to that tag, and they are never removed. Use them for elements you create once — a toolbar, a card shell. Do not put them on a row template that gets cloned per record per render; put those styles in a real stylesheet class instead.
Element factories
Thin wrappers over createElement with the same option object.
b.createDiv({...}) b.createSpan({...}) b.createLabel({ htmlFor, ... })
b.createHeading(level, {...}) b.createButton({...})
b.createInput({ type = "text", placeholder, ... })
b.createCheckbox({ checked, ... }) b.createRadio({ name, checked, ... })
b.createTextarea({ placeholder, rows, cols, ... })
b.createSelect({ options, children, ... }) b.createOption({ value, label, selected, disabled })
b.createImg({ src, alt, ... }) b.createForm({...})
b.createText(string) b.createSvg(pathData, size = 24, classList = [])
b.createFragment()
Notes worth knowing:
createLabeltakeshtmlFor, which it writes as theforattribute.createSelectacceptsoptionsas an array of strings or{ value, label, selected, disabled }objects, and merges them ahead of anychildrenyou pass.createCheckbox/createRadioset.checkedas a property after creation, which is the only thing that works reliably.createTextreturns a text node, and returns an empty one for non-strings — safe to call with anything.createSvgis for simple single-path icons on a0 0 24 24viewBox withfill="currentColor". For real icons useAppsIcons.get(name, theme, size, color).
Tables
Structural elements
b.createTableRow({...}) // <tr>
b.createTableCell({...}) // <td>
b.createTableHead({...}) // <th>
b.createTableHeadSection({...}) // <thead>
b.createTableBody({...}) // <tbody>
b.createTablefoot({...}) // <tfoot>
Use these when a data manager surface has layoutType: "table" — the renderer
puts your template inside a real <tbody>, and a <div> there is invalid
markup that browsers will relocate.
createTable — the standalone table
A self-contained data table, independent of the data managers:
const container = b.createTable({
data: rows,
columnTemplates: { status: (value, row) => b.createSpan({ text: value }) },
pageSize: 25,
enableSearch: true,
enableCSVExport: true,
editable: false,
dragDrop: false,
headerClasses: [], bodyClasses: [], searchClasses: [],
exportBtnClasses: [], paginationClasses: [],
});
Returns a wrapper with _table and _refs (container, table, thead,
tbody, searchInput, exportBtn, paginationContainer) attached. State
lives on table._tableState (currentPage, sortColumn, sortAsc,
pageSize, originalData, filteredData).
Reach for this only for a quick standalone view. Anything backed by an app
method should go through AppDataManager so it inherits permissions,
pagination and the edit modals.
Lists
b.createUL({ children: ["Alpha", "Beta", { text: "Gamma", classList: ["active"] }] });
b.createLI({ text: "Delta" });
createUL normalises its children: nodes pass through, strings and option
objects become <li> elements.
DOM manipulation
All of these are null-safe — passing a missing element is a no-op rather than a throw.
Inserting and removing
b.append(target, child) b.prepend(target, child)
b.insertBefore(newNode, reference) b.remove(node)
b.replace(oldNode, newNode) b.replaceMany(oldNodes, newNode)
b.clearChildren(element) b.clearChildrenHTML(node)
b.moveAfter(nodeA, nodeB) b.moveBefore(nodeA, nodeB)
b.clone(node, deep = true) b.cloneMany(nodes, deep = true)
b.inject(parent, children, { clear = false, prepend = false })
inject is the convenient one — it takes a single node or an array, optionally
clears the parent first, and ignores anything that is not an HTMLElement.
Content and attributes
b.text(node) b.text(node, "new") // getter / setter
b.html(node) b.html(node, "<b>x</b>")
b.setAttributes(node, { role: "button" })
b.removeAttributes(node, ["role", "tabindex"])
b.css(node, { color: "red" })
b.show(node, "flex") b.hide(node)
Classes
b.addClass(node, "a", "b") b.removeClass(node, "a")
b.toggleClass(node, "a", force) b.hasClass(node, "a")
Traversal
b.closest(node, sel) b.matches(node, sel)
b.parent(node) b.children(node)
b.next(node) b.prev(node)
b.find(node, sel) b.findAll(node, sel)
b.getById(id)
b.getTagName(el) b.getTagType(el) // "inline" | "block" | "form" | "other"
b.spElementCount(list)
Create-and-append shorthands
b.appendDiv(parent, props) b.appendHeading(parent, level, props)
b.appendButton(parent, props) b.appendInput(parent, props)
b.appendCheckbox(parent, props) b.appendRadio(parent, props)
b.appendSelect(parent, props) b.appendTable(parent, props)
Each creates the element, appends it, and returns it. The tab callbacks use
dom.appendDiv(tab, {...}) for exactly this reason.
Query API
b.query(root) returns a chainable wrapper. Root defaults to the current
context's root, or document.
b.query(container)
.find(".row")
.filter(el => el.dataset.status === "active")
.addClass("sp-active-row")
.on("click", handler);
Traversal: find, closest, parent, children, next, prev,
first, last, nth, filter
Selection: byName, byClass, byId, byAttr, hasAttr, ofType,
textContains
Manipulation: addClass, removeClass, css, text, html, append,
prepend, remove, replace
Events: on, off
Terminal: get(), forEach(), map(), first(), last(), nth()
Two behaviours to watch: append and prepend insert a clone into each
matched node, so one node passed to five matches becomes five copies; and
text() / html() with no argument return an array of values, one per
matched node.
byName checks the context registry first, then [data-name], [name] and
#id within the matched nodes, then walks up to the parent context. It is the
lookup that pairs with parentContext.exposeElement.
Form values
b.getValue(node) // type-aware read
b.setValue(node, value) // type-aware write
b.resetValue(node) // back to defaultValue / defaultChecked
b.clearValues(...nodes)
b.collectValues(container) // { name: value } for every named input
b.populateValues(container, data) // the inverse
getValue handles the cases you would otherwise write by hand: checkboxes
return a boolean; radios return the checked value from the whole named group;
multi-selects return an array; textareas and inputs return .value; anything
else returns textContent.
collectValues and populateValues key on the name attribute and skip
elements without one.
Select helpers
b.mapOptions(data, valueKey, labelKey)
b.populateSelect(selectEl, data, placeholder = "Choose...", placeholderValue = "")
b.resetSelect(selectEl, isDisabled = false, placeholder = "Select an option...")
b.showLoading(selectEl, isDisabled = false, text = "Loading...")
mapOptions normalises rows into { value, label }. Rows that already look
like options pass through unchanged, which is why it works directly on the
output of the PHP formatForCategory. labelKey may be a function.
populateSelect unwraps { element } wrappers, clears the select, adds the
placeholder, then the options — and renders a "No <placeholder>" entry when the
data is empty, so a select is never silently blank.
Conditional rendering
when()
A declarative rule runner covering logic, classes, attributes, visibility and content in one call.
b.when({
rowHighlight: {
when: () => this.logic.selectedRowUid === rowUid,
el: rootEl,
addClass: "sp-active-row",
do: () => { rootEl.style.borderLeft = "solid 2px orange"; },
else: () => { rootEl.style.borderLeft = "none"; },
},
paginationVisibility: {
when: () => shouldShowPagination,
el: this.elements.paginationWrapper,
show: "sp-flx",
hide: "sp-dis-none",
},
}, { firstMatchOnly: false, context: b });
Rule keys:
| Key | Effect |
|---|---|
when | Boolean or function. Errors are caught and treated as false |
do / else | Callbacks, invoked with context as this |
el | Target element for the DOM effects below |
show / hide | Class pair — adds one, removes the other, and swaps on false |
addClass / removeClass | Added on true, removed on false (and vice versa) |
attr | { name: value } — set on true, removed on false |
text / html | Value or (ok) => value |
firstMatchOnly: true stops after the first rule whose condition passes.
renderIf()
b.renderIf({ operation: row.isActive, ifTrue: activeBadge, ifFalse: "" });
A ternary with names. Useful inside a children array where a bare ternary
would hurt readability.
Modals and forms
createModal
const modal = b.createModal({
title: "Confirm Delete",
message: "Are you sure?",
confirmText: "Yes",
cancelText: "Cancel",
closeAfterConfirm: true,
handlers: {
confirm: async (modalEl) => { /* return false to keep it open */ },
cancel: () => {},
},
});
document.body.appendChild(modal.modal);
Returns { modal, content, footer, cancelBtn, confirmBtn }. You append it
yourself — the builder does not.
Three behaviours:
confirmText: ""orcancelText: ""omits that button. With only one button it goes full width.- Confirm validates first. Any
[required]input insidecontentthat is empty gets a red border and a pink background, and the handler does not run. - Returning
falsefromconfirmkeeps the modal open even whencloseAfterConfirmis true. That is how a failed save stays on screen.
Replace modal.content to build a form dialog:
modal.content.innerHTML = "";
modal.content.appendChild(await distUI.render(entityList));
document.body.appendChild(modal.modal);
showFormModal
A modal that builds its own fields and collects the values for you:
b.showFormModal({
title: "Assign Reviewer",
confirmText: "Assign",
fields: {
reviewer: { type: "select", label: "Reviewer", options: reviewerOptions },
dueDate: { type: "date", label: "Due" },
notify: { type: "checkbox", label: "Send notification" },
},
onConfirm: async (values, contextData, api) => {
// return false to keep the modal open
},
}, api, selectedRows);
createSmartInput
The field factory behind the form modal — label plus the right control:
const { input, label, realInput } = b.createSmartInput("status", {
type: "select",
label: "Status",
options: [{ value: "active", label: "Active" }],
value: "active",
});
select returns a wrapper containing a client-side search box and the select;
read the value from realInput, not input. checkbox returns the label
folded into the wrapper. Everything else returns the input directly.
Menus and navigation
createToggableMenu
The dropdown behind every "Tools" and "More" button:
b.createToggableMenu({
title: "Tools",
headerIconChildren: [b.createDiv({ html: AppsIcons.get("plus", "dark", "small") })],
sections: [
[
{ LABELLINE: "Structure" },
{ label: "Schools", icon: "", onClick: async (e) => { /* … */ } },
{ label: "Programs", children: [ { label: "Import", onClick } ] },
],
[ { label: "Grading Scale", onClick } ],
],
});
Each top-level array is a section, rendered with a divider between sections. An
item with LABELLINE renders as a muted heading. An item with children gets a
hover fly-out.
The panel is position: fixed, appended to document.body, and positioned on
open with viewport flipping so it never runs off screen. Opening one closes all
other menu and profile panels.
The panel is never removed. Each
createToggableMenucall leaves a permanent node ondocument.body, plus a permanentdocumentclick listener. Build your menus once in the constructor or in a method that runs once per window — not inside a render loop.
createProfileMenu
Same mechanics, shaped for a user identity: avatar (or a generated initial),
display name, email, and sections below. Returns the wrapper with _refs
including menuPanel.
createTopBar
const bar = b.createTopBar({ menuChildren, utilityChildren, switchUtilityChildren: true });
bar.menu.update([...]); bar.menu.show(false); bar.menu.clear();
bar.utility.update([...]); bar.utility.show(true); bar.utility.clear();
createTabPanel
A searchable, filterable list panel — not the window tab strip. Returns the
container with _refs and a ui({ search, filter }) toggle, plus
_refs.updateContent(newItems) which swaps the list inside a view transition.
createCategoryPop
A compact button-plus-popup category picker. The trigger's label updates to the chosen category.
createCategoryNavigation
A horizontal, paged strip of category chips backed by a remote method:
const nav = await b.createCategoryNavigation({
networkContext: { engine: this.engine, apiConfig: ApiConfig },
appName: "SchoolManagerApp",
method: "getProgramCategoryForSelect",
params: [],
maxVisible: 6,
withAllOption: true, allLabel: "All", allValue: "all",
elType: "radio",
onCategoryChange: (value, checked, e) => { /* … */ },
});
// nav.element, nav.refs.{prevButton,nextButton,container}, nav.actions.refresh()
Selection survives paging — the component tracks the chosen value rather than relying on the rendered checkboxes.
createRemoteSelector
A select plus a Refresh button, wired to an app method and registered with the parent context in one call:
const selector = b.createRemoteSelector({
parentContext: this.parentContext,
exposeName: "schoolSelect",
iconRegistry: AppsIcons,
networkContext: { engine: this.engine, apiConfig: ApiConfig },
appName: "SchoolManagerApp",
method: "getSchoolsForSelect",
params: [],
placeholder: "Schools",
onChange: async (value, event) => { /* … */ },
});
// selector.element, selector.refs.{select,button}, selector.actions.refresh(btn?)
exposeName is what paramSource and elementsToModify refer to in header
actions. It fetches once on creation.
createRemoteSelectorandcreateCategoryNavigationfall back to a bareApiConfigidentifier whennetworkContextis omitted, and that identifier is not imported in this module — omittingnetworkContextthrows aReferenceError. Always pass it.
createDropdownWithRadio
A radio-list dropdown with an optional search box.
container.updateOptions(newOptions) replaces the list.
createSelectorHighlight
A checkbox list where checking an item highlights its row. Returns the container
with _refs.items for direct access.
createToggableWidget
A collapsible titled panel with a rotating arrow. isOpen sets the initial
state; _refs exposes header, contentArea, titleArea, arrow.
createSlideInPanel
A right-hand drawer with a backdrop:
const panel = b.createSlideInPanel({ title: "Settings", children: [...] });
panel.open(); panel.close();
Clicking the backdrop or the close button dismisses it; the DOM is removed after the transition.
Data containers
Two list widgets for the app's left panel or sidebars.
staticDataContainer
const list = b.staticDataContainer({
title: "Programs",
contentItems: [{ id: "p1", text: "Theology", data: { id: "p1" } }],
selectable: true, multiple: false,
searchable: true, searchPlaceholder: "Search...",
AppsIcons, iconName: "spreadsheet",
showHeader: true, showActions: true, actions: [someButton],
onSelect: (items, e) => {},
});
// list.wrapper, list.refs, list.updateContent(items), list.appendActions(els, opts)
onSelect receives [{ id, text, data }]. With multiple: true you also get
Select All / Deselect All in the footer.
serverDataContainer
The same widget, fetching its own data:
this.refs.categories = b.serverDataContainer({
title: "Programs",
searchable: true, showHeader: true, showActions: true,
actions: [manageProgramBtn],
AppsIcons,
dataSource: { appName: "SchoolManagerApp", method: "getProgramsForSelect", params: [""] },
networkContext: { engine: this.engine, apiConfig: ApiConfig },
onSelect: async (selectedItems, e) => { /* … */ },
});
// later, re-scope it
this.refs.categories.actions.setParams([schoolUid]);
await this.refs.categories.actions.refresh();
Search filters the loaded set client-side.
It calls
refresh()in its constructor without awaiting or catching. A failed initial load surfaces as an unhandled rejection and a permanently empty list. Callrefresh()yourself afterwards if you need to know whether it worked.
Cards and widgets
createCard / createActionCard
A metric card. createCard takes a value; createActionCard fetches its own.
const card = b.createActionCard({
statusText: "Students",
label: "Enrolled this year",
variant: "success", // neutral | success | warning | danger
formatter: (v) => Number(v).toLocaleString(),
delta: { direction: "up", value: "12%" },
dataSource: { appName: "SchoolManagerApp", method: "countEnrolled", params: [] },
networkContext: { engine: this.engine, apiConfig: ApiConfig },
onClick: () => {},
ui: { showValue: true, showStatus: true, showLabel: true, showDelta: true },
});
// card.element, card.refs, card.api.setVariant("danger"), card.actions.refresh(overrideParams?)
refresh(overrideParams) re-fetches with different arguments — how a card
re-scopes when a filter above it changes.
createProgressCard / createActionProgressCard
Value against a target, with a bar and a percentage.
const p = b.createActionProgressCard({
targetText: "Capacity", target: 500,
dataSource: {...}, networkContext: {...},
ui: { showValue: true, showStatus: true, showHeader: true, showBar: true, showPercent: true },
});
p.formulas.updateProgress(currentValue, maxValue);
p.actions.refresh(newParams);
The fetch reads the count from response.table and an optional response.target
for the denominator.
createApprovalCard
A sign-off list with stars, per-approver rows and an Approve button gated to the owning user:
b.createApprovalCard({
title: "Budget Approval",
approvals: [{ uid, role, name, email, approved: false, timestamp: "" }],
userUid: currentUserUid,
locked: false,
onApprove: (item, index) => { /* persist */ },
});
// .api.lock(true)
Only the row whose uid matches userUid gets a live button, and approving
opens a confirmation modal first. onApprove is where you persist — the widget
only updates its own display.
createDataView
The minimal template renderer: finds [data-template="true"] inside a
container, clones it per row, and fills [data-key="…"] elements.
b.createDataView(rows, container);
Fine for a static list. Anything needing permissions, paging or editing belongs
in AppDataManager.
Settings controls
b.createSettingsGroup(title, children)
b.createControlVertical(label, controlElement)
b.createControlHorizontal(label, controlElement)
b.createModernSwitch(key, initialValue, updateFn)
createModernSwitch returns a toggle that manages its own visual state and
calls updateFn(key, newValue) on each change. Vertical stacks the label above
the control; horizontal puts them on one line.
Rich text and selection
Reading the selection
b.getSelection() b.getSelectionRange()
b.getSelectionAnchor() b.getSelectionFocus()
b.getSelectedText()
b.isSelectionInside(container)
b.selectionContainsNode(node, allowPartial = true)
b.selectionIntersectsNode(range, node)
b.replaceSelection(stringOrNode)
b.getRangeFromPoint(x, y)
Formatting
b.toggleFormat("strong", editorEl) // toggles: removes if already applied
b.wrapSelection("mark", editorEl)
b.unwrapSelection("mark", editorEl)
b.isTagActive("strong", editorEl) // treats <b> as <strong>, <i> as <em>
b.getActiveTags(["strong","em","ul"], editorEl)
toggleFormat avoids surroundContents — it extracts, wraps and re-inserts,
which survives selections spanning element boundaries. Lists get special
handling: applying wraps the selection in a single <li>; removing flattens
each <li> back to text.
Toolbars
b.bindSelectionToolbar(editorEl, { "#btn-bold": "strong", "#btn-italic": "em" });
b.toggleFormatButton("#btn-bold", "strong", editorEl);
b.toggleFormatButton("#btn-link", "a", editorEl); // opens the link popup
bindSelectionToolbar listens on selectionchange, keyup and mouseup, and
toggles sp-bg-gray-200 on the matching buttons. openLinkPopup prompts for a
URL, applies rel="noopener noreferrer", and unlinks instead when the selection
already contains a link.
Media previews
b.buildImagePreview(src) // click opens in a new tab
b.buildVideoPreview(src) // controls, metadata preload
b.buildYouTubeEmbed(url) // returns null if the URL has no 11-char video id
b.buildAudioPlayer(src, isRight) // play/pause, scrubber, elapsed time
buildAudioPlayer is a full custom player, not a native <audio controls>.
isRight flips its palette for right-aligned chat bubbles.
Drag and drop
b.dragAndDrop(dragItem, dropContainer, {
dragType: "application/sp-component",
acceptTypes: ["application/sp-component", "text/plain"],
autoAppend: true,
onDragStart: (item, e) => {},
onDragOver: ({ event, item, container }) => {},
onDragEnd: (item) => {},
onDrop: ({ item, container, event }) => {},
});
Adds sp-dragging to the item and sp-drag-over to the container for styling.
With onDrop supplied nothing moves automatically — you own the reordering.
Set autoAppend: false to prevent the default move without supplying a handler.
View transitions
b.startViewTransition(() => { /* DOM updates */ });
b.transitionUpdate(element, (el) => { /* mutate */ });
b.replaceChildrenWithTransition(parent, ...children);
b.setContentWithTransition(parent, html);
b.toggleVisibilityWithTransition(element, show);
b.applyWindowAnimation(win, "scale" | "slide-bottom" | "slide-top" | "morph" | "zoom");
All fall back to running the callback immediately where the View Transitions API is unavailable, so they are always safe to call.
CSS Typed OM
b.readProperty(el, prop, { computed = true, toString = false, toUnit });
b.setTypedProperty(el, prop, cssValue);
b.cssTypedFactory(type, value, options);
readProperty tries, in order: the DOM property (when computed: false), the
computed style map, the inline style map, string-based computed style, then the
HTML attribute. toUnit converts a CSSUnitValue ('px', 'em', …), and
converts pixel strings to numbers in the fallback path.
cssTypedFactory types: unit, keyword, math (with
options.operation of sum/product/negate/invert/max/min),
translate, rotate, scale, skew, transform, position, unparsed,
parse, parseAll.
const width = b.cssTypedFactory("unit", 240, { unit: "px" });
b.setTypedProperty(panel, "width", width);
You rarely need these directly — attributes.style already routes through the
Typed OM. Reach for them when animating a numeric property or reading a computed
value as a number rather than a string.
Utilities
withLoading
await b.withLoading(btn, async () => {
await doTheThing();
}, "Saving...");
Swaps the element's content for a spinner and the loading text, disables pointer
events, and restores everything in a finally — so it survives a throw. Guarded
against double-invocation via data-loading.
Wrap every async click handler in this. The reference app does it on every button that fetches.
showNotification — static
DomBuilder.showNotification("Saved", "success", 3000);
Types: success, error, warning, info. Toasts stack top-right in a shared
container, fade in, and remove themselves. Static — call it on the class,
not on your instance.
Text and misc
b.trimText(text, max = 100) // character truncation with an ellipsis
b.extractAcronym("LibraryManager") // "LM"
b.extractAcronym("HTTPRequestHandler", 2) // "HR"
b.getWeekDateRange() // { startDate, endDate } — Sunday to Saturday, ISO dates
b.getActiveTheme(shell, themeFn) // resolves the active theme object for the shell's mode
extractAcronym splits camelCase and consecutive capitals correctly, so
userAccessController gives UAC.
Conventions
Build declaratively. Prefer one nested createDiv({ children: [...] }) over
imperative appendChild chains. It reads closer to the markup it produces.
Alias the builder. const b = this.dom; at the top of a template method
keeps deeply nested structures readable.
Keep references. Capture elements you will address later:
(this.refs.programSelect = b.createSelect({ /* … */ }))
That inline-assignment pattern appears throughout the reference app — it stores
the reference and passes the element to children in one expression.
Expose across the app. For elements other components address by name:
this.parentContext.exposeElement("programsSelect", this.refs.programSelect);
Use theme variables, not literals. var(--theme-color),
var(--theme-bg-btn), var(--theme-fore-color-btn), var(--window-bg),
var(--window-border), var(--window-glow), var(--surface-active),
var(--bg-layer-2..4), var(--accent-active), var(--action-update),
var(--action-delete), var(--text-muted). Hard-coded colours break the dark
theme.
Prefer utility classes for layout. sp-flx, sp-ofx-col, sp-alx-c,
sp-jc-bx, sp-g-q2…sp-g-q4, sp-pad-rem-*, sp-b-r-*, sp-f-rem-*,
sp-wd-*, sp-b-th, sp-hx-nb, sp-cursor-pointer, custom-scroll. Use
attributes.style for the one-off values a utility class does not cover.