Skip to content
Article DomBuilder reference
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

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

Every other factory funnels through this one.

b.createElement(tag, {
  id, classList = [], text = "", html = "", value = "", type = "",
  required = false, elEvent = {}, attributes = {}, pseudo = {},
  hover = null, children = [],
});
OptionNotes
tagAny HTML tag name
idSets el.id
classListArray of strings. Empty and whitespace entries are dropped
textSets textContent. Takes precedence over html
htmlSets innerHTML, only when text is empty
valueApplied when the element has a value property and the value is non-empty
typeFor inputs. Invalid types are swallowed
requiredAdds the required attribute
elEvent{ click: fn, change: fn } — added with addEventListener
attributesSee below
pseudo{ "::before": {...}, ":focus": {...} }
hoverStyle object applied on :hover
childrenNodes 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. registerHeaderActiondata-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 pseudo or hover adds 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:

  • createLabel takes htmlFor, which it writes as the for attribute.
  • createSelect accepts options as an array of strings or { value, label, selected, disabled } objects, and merges them ahead of any children you pass.
  • createCheckbox / createRadio set .checked as a property after creation, which is the only thing that works reliably.
  • createText returns a text node, and returns an empty one for non-strings — safe to call with anything.
  • createSvg is for simple single-path icons on a 0 0 24 24 viewBox with fill="currentColor". For real icons use AppsIcons.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:

KeyEffect
whenBoolean or function. Errors are caught and treated as false
do / elseCallbacks, invoked with context as this
elTarget element for the DOM effects below
show / hideClass pair — adds one, removes the other, and swaps on false
addClass / removeClassAdded on true, removed on false (and vice versa)
attr{ name: value } — set on true, removed on false
text / htmlValue 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: "" or cancelText: "" omits that button. With only one button it goes full width.
  • Confirm validates first. Any [required] input inside content that is empty gets a red border and a pink background, and the handler does not run.
  • Returning false from confirm keeps the modal open even when closeAfterConfirm is 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.


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 createToggableMenu call leaves a permanent node on document.body, plus a permanent document click 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.

createRemoteSelector and createCategoryNavigation fall back to a bare ApiConfig identifier when networkContext is omitted, and that identifier is not imported in this module — omitting networkContext throws a ReferenceError. 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. Call refresh() 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-q2sp-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.