JS behaviors
The framework is CSS-first, not CSS-only. 26 small behaviors (zero dependencies) cover what the platform can't do or what accessibility demands: each is a call-once init using document-level event delegation, so DOM swaps (HTMX, Alpine, anything) never require re-binding.
The inits generated
| Init | What it adds | DOM hooks it reads |
|---|---|---|
| initDialogs() | Dialog behavior via event delegation — no per-element binding, no DOM flags. | data-dialog-trigger, data-dismissible, data-state |
| initDataTables() | Optional table behavior (~1 kB): wires select-all, keeps an O(1) data-any-selected attribute on the container (so CSS needn't re-scan every row via :has() on large grids), and maintains a live "n… | .bo-data-table-container, .bo-data-table__row-select, .bo-data-table__select-all, .bo-data-table__selection-count, data-any-selected, data-selected-count-override |
| initDataGrid() | Opt-in ARIA grid pattern for .bo-data-table (WAI-ARIA APG "Grid", the interactive-widgets variant). | .bo-data-table, .bo-data-table-container, .bo-data-table__row-select, .bo-data-table__select-all, data-col, data-grid-nav, data-table |
| initRowEdit() | Multi-row inline edit: tracks which rows have unsaved changes, shows a per-row Save/Cancel affordance, and dispatches a `bo:row-save` event for your code to persist (fetch, hx-patch, form submit —… | .bo-btn, .bo-btn--ghost, .bo-btn--icon, .bo-btn--secondary, .bo-btn--sm, .bo-data-table, .bo-data-table__row-edit-actions, .bo-icon, .bo-icon--close, .bo-icon--save, .bo-input, .bo-input--seamless, data-row-edit, data-row-edit-cancel, data-row-edit-dirty, data-row-edit-save, data-row-id, data-row-state |
| initTableToolbar() | Optional data-table toolbar actions: column visibility + export. | .bo-btn, .bo-btn--secondary, .bo-checkbox, .bo-data-table, .bo-data-table-container, .bo-data-table__toolbar, .bo-dropdown__menu, data-col, data-col-toggle, data-multiselect, data-multiselect-label, data-table-export, data-table-export-format |
| initStickyCols() | Multiple frozen columns (roadmap 72.1) — opt-in via [data-sticky-cols], "2" | "3" only. | .bo-data-table, .bo-data-table--sticky-col, .bo-sticky-w-1, .bo-sticky-w-2, data-sticky-cols, data-table |
| initTableSum() | Declarative realtime column totals — the auto-sum half of Slice 18's subtotal contract (a deliberate, named exception to "you do the data"; the other half is listening to `bo:cell-change` yourself for… | .bo-data-table__col--numeric, data-decimals, data-sum-of |
| initTreeTable() | Expand/collapse for hierarchical table rows — document-level delegation, call initTreeTable() once. | .bo-data-table, .bo-tree-table, .bo-tree-table__toggle, data-sum-of, data-tree-level, data-tree-table |
| initLoadMore() | Load-more pagination ("pull up to see more") — the infinite-scroll alternative to page-number `.bo-pagination`. | .bo-btn, .bo-btn--secondary, .bo-data-table, .bo-data-table-container, .bo-data-table__footer, .bo-pagination, data-load-more-auto, data-table-load-more |
| initWindowedList() | Windowed list: server-chunked rows with client-side memory release (roadmap 30.4b) — for SCANNING a large list, not searching one. | .bo-app-shell__main, .bo-btn, .bo-btn--secondary, .bo-data-table, .bo-data-table-container, .bo-data-table__footer, .bo-data-table__row-select, .bo-data-table__spacer, .bo-density-row-height, data-chunk-id, data-chunk-offset, data-chunk-size, data-evicted, data-grid-nav, data-load-more-auto, data-row-id, data-selected-count-override, data-table, data-table-load-more, data-table-total-rows, data-window-chunks, data-windowed, data-windowed-selection-host |
| initAnchorNav() | Anchor navigation for long detail screens (the "object page" floorplan). | .bo-anchor-landing-offset, .bo-app-shell__main, .bo-motion-duration-slow, .bo-pagination, .bo-sidebar-nav, .bo-sticky-w-1, data-anchor-collapse, data-anchor-nav, data-state |
| initCollapsibleCards() | Collapsible card sections (.bo-widget). | .bo-widget__collapse, data-collapse-trigger, data-state |
| initSavedViews() | Saved-view / filter-bar persistence. | .bo-filter-bar, data-saved-views |
| initWizard() | Multi-step wizard flow — one panel visible at a time, with a bo-stepper kept in sync. | .bo-stepper, .bo-stepper__step, data-state, data-wizard, data-wizard-back, data-wizard-current, data-wizard-next, data-wizard-panel, data-wizard-submit |
| initQuantity() | Increment/decrement buttons for .bo-quantity — document-level delegation, call initQuantity() once. | .bo-money__currency, .bo-quantity, .bo-quantity__input, .bo-quantity__step, .bo-quantity__unit-select, data-decimals, data-quantity-step |
| initMoneyField() | Currency-aware money field — a currency <select> linked to an amount input: changing currency updates the input's `step` (decimal precision) and reformats the current value to that precision. | .bo-input, .bo-input--numeric, .bo-money, .bo-money__amount, .bo-money__currency, .bo-select, data-decimals |
| initGroupedNumber() | Grouped display for numeric inputs — thousands separators via `Intl.NumberFormat`, formatted ON BLUR, never while typing (123.1: what Reckon ships, what SAP's own guidance steers to after live… | .bo-input, .bo-input--numeric, data-decimals, data-grouped, data-locale |
| initScanInput() | RF/handheld barcode-scanner input. | .bo-input, .bo-input--code, .bo-visually-hidden, data-scan-flash, data-scan-input, data-scan-result, data-scan-status, data-scan-terminator |
| initFileDropzone() | Drag-and-drop convenience for a `.bo-file-dropzone`. | .bo-file-dropzone, .bo-file-input, .bo-visually-hidden, data-dragover, data-file-dropzone |
| initTagInput() | Multi-value tag/chip entry (Fiori Token/MultiInput; Ant/Carbon Tag) — multi-tag cost centers on a record, multiple approval-routing recipients. | .bo-alert, .bo-tag-input, .bo-tag-input__field, .bo-tag-input__remove, .bo-tag-input__tag, data-grid |
| initValidationSummary() | Inline validation summary. | .bo-alert, .bo-alert--danger, data-validation-summary, data-validation-summary-box |
| initTabs() | ARIA tabs behavior via document-level delegation — call initTabs() once. | .bo-tabs--vertical, .bo-tabs__list, .bo-tabs__tab, data-overflow |
| initDropdowns() | Popover-based dropdown menus. | .bo-btn, .bo-btn--secondary, .bo-checkbox, .bo-dropdown__item, .bo-dropdown__menu, .bo-dropdown__menu--end, data-multiselect, data-multiselect-count, data-multiselect-label |
| initContextMenu() | Right-click (contextmenu) trigger for the SAME popover menu component initDropdowns() already opens on click (roadmap 73.1). | .bo-checkbox, .bo-dropdown__item, .bo-dropdown__menu, data-col-toggle, data-context-menu |
| initCombobox() | Editable combobox with list autocomplete — WAI-ARIA APG combobox pattern (single-select, list autocomplete, manual selection). | .bo-combobox, .bo-combobox__group, .bo-combobox__listbox, .bo-combobox__option, .bo-combobox__option-label, .bo-input, .bo-visually-hidden, data-name, data-open-on-focus, data-value |
| initAlerts() | Dismiss behavior for alerts and toasts: any .bo-alert__dismiss button removes its enclosing .bo-alert. | .bo-alert, .bo-alert__dismiss, .bo-toast, data-state |
Keyboard support generated
The four behaviors that own their own key handling — everything else
is native (a plain <input>, a native <dialog>'s
own focus trap) or has no keyboard surface (delegation-only behaviors like
alerts). Extracted from source, so it can't drift from what's actually
wired.
| Behavior | Key | Does |
|---|---|---|
| initCombobox() | ArrowDown / ArrowUp | open the list (filtering first if closed), then move the active option; clamps at the ends |
| Enter | commit the active option (dispatches bo:combobox-select) | |
| Enter (list open, nothing active) | swallowed, never submits the form; commits the sole match if there is exactly one | |
| Escape | close without changing the value (native popover light-dismiss) | |
| Tab | focus leaves the widget: the list closes and the active option clears | |
| (focus after a commit) | the committed text is selected, so typing browses the full list again | |
| initDataGrid() | ArrowRight / ArrowLeft / ArrowDown / ArrowUp | move the cell cursor; clamps at the grid edges |
| Home / End | jump to the first/last cell in the row; with Ctrl/Cmd, the first/last cell in the grid | |
| Enter | focus the cell's one interactive widget, if it has one | |
| Escape | return focus from a widget to its parent cell | |
| initDialogs() | Escape | close (native showModal(); skipped if data-dismissible="false") |
| Tab / Shift+Tab | cycle focus inside the dialog, wrapping at the ends (focus trap) | |
| initTabs() | ArrowRight / ArrowLeft | move to the next/previous tab in a horizontal strip, wrapping; activates immediately |
| ArrowDown / ArrowUp | the same, in a vertical rail (.bo-tabs--vertical) | |
| Home / End | jump to the first/last tab; activates immediately |
The delegation model
Each init installs one document-level listener and resolves its target at
event time; element-level listeners (a dialog's close handler) bind lazily on
first use, tracked in a WeakSet — never serialized into the DOM.
What you can rely on:
- Triggers may render before their dialog exists.
- Swapped-in content works without re-init.
- Clone / morph swaps can't strand stale state.
Event wiring is swap-proof; derived state is re-computed. The
one HTMX-aware line in core JS is the htmx:after:swap event
name a table container listens for:
// Bound table containers re-derive selection state automatically when an
// htmx:after:swap bubbles through them. Any OTHER swap mechanism calls:
import { refreshDataTable } from "@busy-office/ui/js";
refreshDataTable(container);
// Only NEW containers swapped in later need re-binding:
document.body.addEventListener("htmx:after:swap", (e) => initDataTables(e.target)); Intent events
Behaviors report user intent through DOM events — your code decides what to persist. The full generated list (name, target, when it fires, payload) is the intent-event index; it is look-up material, not something to read once.
State attributes generated
Two attributes, deliberately kept separate rather than merged into one lifecycle vocabulary — a component is either OPEN or BUSY, and conflating the two would make "is this dialog open while its save is in flight" unrepresentable in one value.
| Attribute | Values | Means |
|---|---|---|
| data-state | closed|closing|current|done|open|pending|rejected|resolved | structural: is this open or closed (dialog, offcanvas, dashboard cards, the motion module's collapse), or where a multi-step control is in its own sequence (stepper, approval-workflow's timeline). Same attribute name, two genuinely different domains — read it per component, never assume the full value set applies everywhere it appears. |
| data-loading | true | busy: an async request is in flight (button, data-table).
The opt-in @busy-office/ui/css/htmx module bridges
it onto HTMX's own request state — .htmx-request
[data-loading] disables pointer events for the
duration with no JS wiring on your part. |
Where a component's states don't fit either
shape, it gets its own attribute rather than overloading these two:
data-row-state (dirty|error|warning)
on data-table rows, data-day
(closed|holiday|selected|today) on the calendar. A shared
name across unrelated domains is a worse contract than two honestly
different ones.
The Save sequence
The pattern behind every Save/Submit/Approve button in this framework's pattern catalogue, as it actually composes today — no dedicated "loading state" component, because the pieces above already cover it:
- Idle — the button reads "Save".
- Saving — your JS (or the HTMX bridge, for free)
sets
data-loading="true"andaria-busy="true"on the button, and swaps its text to "Saving…". Two channels: the text change is what a sighted user reads;aria-busyis what a screen reader announces. Neither alone is the contract. - Saved (or Error) — clear both attributes and
restore the button's resting label, or route to your error state
(a field's
aria-invalid, a summary, a toast — pattern- specific, not this button's job).
Deliberately no spinner icon or
success checkmark on the button itself — a real, measured
decision, not an oversight: an opacity-dimmed solid-fill button drops
to roughly 3.24:1 text contrast (axe color-contrast), under the 4.5:1
minimum. The button's own visible channel stays the text swap plus
cursor: progress; a spinner or checkmark belongs beside
or inside the button as your own markup if the pattern wants one —
the framework does not force a specific glyph onto every Save action.
Import paths
import { initDialogs } from "@busy-office/ui/js"; // barrel
import { initDialogs } from "@busy-office/ui/js/behaviors/dialog.js"; // granular