@quandis/qbo4.ui
Overview
@quandis/qbo4.ui is a library of framework-agnostic web components (built with Lit 3) plus a set of composable mixins, DI-registered services, and pure utility functions for building forms, data grids, dialogs, and rich content editors. Components use tsyringe for dependency injection and Bootstrap 5 classes for default styling (bring your own Bootstrap, or override with your own CSS).
Most components can be dropped into plain HTML with no JavaScript — configuration comes from attributes and (where noted) <script type="application/json"> slots — but every component also exposes a typed TypeScript API for programmatic use.
Table of contents
- Foundations: Dependency Injection & Services, Mixin Composition (
applyMixins), Fetching Data (QboFetchMixin), Fetch State, Errors & Toasts, URL & JSON Utilities, Real-Time Updates (SignalR), Package Entry Point - Api Endpoint Registration
- Forms: QboFormElement, QboFormMixin, QboForm, QboFieldset, QboFormEdit, QboSelect, QboTypeahead, QboDatalist, QboSelectable, QboFileUpload
- Form Validation
- Data Tables: qbo-table, qbo-table-sort, qbo-table-tabs
- Pagination: qbo-paginate, QboPaginatedListMixin
- Display: QboCard, QboStatusBadge, QboIcon, QboLoadingSpinner, QboLoadingMixin
- Modals & Confirmation Dialogs: QboModal, QboConfirm
- Popups, Popovers, and Menus: QboPopup, QboPopupListener, QboPopover, QboMenu, QboContextMenu
- Tabs
- Alerts & Toasts: QboAlert, QboToast, QboToastMixin
- Rich Content & Editors: qbo-code, qbo-markdown, qbo-docviewer, qbo-flowchart
- Styling: qbo-dynamic-style, Style Mixins
- Utility Components & Helpers: QboKeyboardMixin, Clipboard & Download Helpers, qbo-microphone, qbo-search, qbo-resize, qbo-link, qbo-api-provider, qbo-user-preferences, qbo-features, Error Message Extraction, qbo-log-counter, Utility-Only Entry Point
Dependency Injection & Services
qbo4.ui uses tsyringe for dependency injection, wrapped in a small Services class so the same container is shared across every component and every copy of the package on a page.
export class Services {
public container: DependencyContainer;
public options: any;
getService<T>(token: string | InjectionToken<T>): T {
return this.container.resolve<T>(token);
}
}
The package exports a single services singleton, built from a tsyringe child container (container.createChildContainer()), not the bare tsyringe container. On first load it's stamped onto window.qbo4.services; every subsequent import of @quandis/qbo4.ui — even from a separately bundled script on the same page — reuses that same instance instead of creating a second, disconnected container.
import { services, IApiService } from '@quandis/qbo4.ui';
// Register your own service
services.container.registerInstance<IApiService>('myApi', new MyApi());
// Resolve it later, from anywhere
const api = services.getService<IApiService>('myApi');
// equivalent to: services.container.resolve<IApiService>('myApi')
Always register against
services.container(orservices.getService), never against a bare tsyringecontainerimport.getApiService,qbo-api, andIValidatelookups all resolve throughservices.containerspecifically — registering elsewhere means qbo4 components won't find your service.
Mixin Composition (applyMixins)
Many qbo-* behaviors ship as composable Lit mixins rather than as custom elements. qbo-base.ts provides the applyMixins helper that composes them onto a LitElement subclass, plus a handful of marker interfaces mixins agree to implement. Understanding this pattern makes the rest of this document easier to read — most mixins referenced below (fetch, fetch-state, toast, loading, pagination, keyboard, style, signalr, form) are composed this same way.
type Constructor<T = LitElement> = new (...args: any[]) => T;
type Mixin = (base: Constructor<LitElement>) => Constructor<LitElement>;
export function applyMixins(...mixins: Mixin[]): (base: Constructor<LitElement>) => Constructor<LitElement>;
A "mixin" here is just a function that takes a base class and returns a subclass extending it, following the standard TypeScript mixin pattern. applyMixins reduces a list of mixins left-to-right over a base class, so mixins are applied in the order listed, each wrapping the previous:
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import { applyMixins, QboFetchMixin, QboKeyboardMixin } from '@quandis/qbo4.ui';
@customElement('my-widget')
export class MyWidget extends applyMixins(QboFetchMixin, QboKeyboardMixin)(LitElement) {
// MyWidget now has fetchData()/apiEndpoint from QboFetchMixin
// and onEscape()/onEnter() hooks from QboKeyboardMixin
}
Because TypeScript cannot express n-ary variadic mixin return types,
applyMixinsreturnsConstructor<LitElement>and the richer interface is erased. Declare (or cast) mixin-provided members at the call site if you need to reference them:class MyEl extends applyMixins(QboFetchMixin)(LitElement) { declare apiEndpoint: URL | null; declare fetchData: (headers?: any, payload?: any) => Promise<void>; }
Every mixin must call
super.connectedCallback()/super.disconnectedCallback()/ etc. in any lifecycle hook it overrides. This is the load-bearing contract that lets mixins stack — skippingsupersilently breaks every mixin applied before it in the chain.
qbo-base.ts also declares three marker interfaces that mixins and components use to document their contracts:
| Interface | Contract |
|---|---|
IQboFetchable |
apiEndpoint: URL | null and fetchData(headers?, payload?): Promise<void>. Implemented by QboFetchMixin. |
IQboStylable |
readonly _stylable: true marker. Components implementing it must expose ::part(root) (shadow DOM) or document consumed --qbo-* CSS custom properties (light DOM). Implemented by QboStyleMixin. |
IQboFormElement |
value: FormData, name: string, disabled: boolean, readonly form: HTMLFormElement | null — contract for form-associated custom elements built on ElementInternals. Implementers must set static formAssociated = true, typically via QboFormMixin rather than hand-rolling the plumbing. |
Fetching Data (QboFetchMixin)
QboFetchMixin (from qbo-fetch.ts) is a Lit mixin that gives any component a self-contained "fetch on connect" behavior: it reads optional header/payload configuration from child <script> tags, resolves an IApiService, fetches once the element connects, and stores the result as either parsed JSON or raw text/HTML. Reach for it whenever a component needs to load its own data from an API endpoint without hand-rolling fetch() and lifecycle plumbing — qbo-select, qbo-typeahead, qbo-docviewer, and qbo-popup are all built on it.
API surface
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
apiEndpoint |
apiEndpoint |
URL | null |
null |
Endpoint to fetch from. Resolved through getApiService (supports the api:// scheme below). |
method |
method |
string |
'GET' |
Forced to 'POST' automatically if a payload is present. |
accept |
accept |
string |
'application/json' |
Drives both the request header and how the response is parsed (JSON → jsonData, anything else → html). |
error |
error |
boolean |
false |
Set to true if the fetch throws. |
jsonData |
jsonData |
any |
{} |
Populated when accept is application/json. |
html |
html |
string | null |
null |
Populated when accept is anything else. |
fetchOnLoad |
fetchOnLoad |
boolean |
true |
If true, fetches automatically from connectedCallback. |
headerTag |
headerTag |
string |
'script[name="headers"]' |
Selector for a child <script> holding a JSON object of request headers. |
headers |
(none — attribute: false) |
object | null |
null |
Explicit headers; falls back to getHeaders() if unset. |
payload |
(none — attribute: false) |
object | null |
null |
Explicit request body; falls back to a sibling script[name="payload"] if unset. |
Methods: fetchData(headers, payload), getHeaders(), getPayload(headers) — all overridable in a subclass.
Service resolution: QboFetchMixin first checks for an IApiService provided via @lit/context (see qbo-api-provider below); if none is in scope, it falls back to getApiService(this.apiEndpoint), so named/api:// endpoints registered per the Api Endpoint Registration section below work here too.
Events
qbo-updated(bubbles, composed) — fired after a successful fetch.qbo-error(bubbles, composed,detail: err) — fired if the fetch throws;erroris also set totrue.
Usage
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { applyMixins, QboFetchMixin } from '@quandis/qbo4.ui';
@customElement('my-list')
export class MyList extends applyMixins(QboFetchMixin)(LitElement) {
render() {
return html`
<ul>${(this.jsonData ?? []).map((item: any) => html`<li>${item.name}</li>`)}</ul>
`;
}
}
<my-list apiEndpoint="api://default/people">
<script name="headers">{ "Accept": "application/json" }</script>
</my-list>
QboFetch(a plain, unregisteredQboFetchMixin(LitElement)class) is also exported for cases where you want toextends QboFetchdirectly instead of composing viaapplyMixins.
Fetch State, Errors & Toasts
Two small, composable pieces (qbo-fetch-state.ts and qbo-fetch-state-mixin.ts) handle the "loading / loaded / empty / error" lifecycle around a fetch, independent of QboFetchMixin — useful for imperative save/load flows (button click handlers, form submissions) rather than declarative on-connect fetching.
fetchJsonWithState / sendJsonWithState
Framework-agnostic functions that drive loadState/errorMessage/toast handlers through every branch of a fetch — success, HTTP error, 204 No Content, app-level ("logical") failure on a 200, and network/thrown errors — and never throw; they resolve to the parsed body or null.
export type LoadState = 'idle' | 'loading' | 'loaded' | 'empty' | 'error';
export type ToastType = 'success' | 'error' | 'info';
const data = await fetchJsonWithState<Profile[]>('/api/profiles', {
handlers: {
setLoadState: s => this.loadState = s,
setErrorMessage: m => this.errorMessage = m,
toast: this.toast.bind(this),
},
});
| Option | Default | Description |
|---|---|---|
init |
undefined |
Passed straight through to fetch(). |
handlers |
undefined |
{ setLoadState?, setErrorMessage?, toast? } — any/all optional. |
allowNoContent |
true |
If true, a 204 response sets state to 'empty' instead of 'error'. |
isLogicalSuccess |
undefined |
Predicate on the parsed body; return false to treat an HTTP-200 as a failure (e.g. body => body.succeeded !== false). |
successMessage |
undefined |
Toasted (type 'success') if provided and the fetch succeeds. |
sendJsonWithState(url, body, options) wraps the above for writes: JSON-stringifies body, sets Content-Type: application/json, defaults to POST. postJsonWithState / putJsonWithState are thin method-fixed sugar over it.
Error messages are extracted centrally via qbo-error.ts's extractErrorMessage/parseErrorResponse (see Error Message Extraction below), which understand ProblemDetails (title/detail), ASP.NET model-validation errors objects/arrays, and plain message fields — so your API doesn't need a bespoke error shape for a sensible message to surface.
QboFetchStateMixin
A Lit mixin that turns the above into reactive component state — loadState/errorMessage as @state() properties, plus a fetchHandlers getter shaped exactly for fetchJsonWithState/sendJsonWithState. If composed with QboToastMixin, fetchHandlers.toast is wired up automatically.
import { applyMixins, QboFetchStateMixin, QboToastMixin, fetchJsonWithState } from '@quandis/qbo4.ui';
class ProfileList extends applyMixins(QboFetchStateMixin, QboToastMixin)(LitElement) {
async load() {
const data = await fetchJsonWithState<Profile[]>('/api/profiles', {
handlers: this.fetchHandlers,
successMessage: 'Profiles loaded',
});
}
}
URL & JSON Utilities
A grab-bag of small, dependency-free helpers (qbo-url.ts, qbo-json.ts) used throughout the package for building request URLs and reading loosely-shaped JSON.
buildUrl (qbo-url.ts)
Combines path-template substitution and query-string building in one call:
import { buildUrl } from '@quandis/qbo4.ui';
buildUrl(this.baseUrl, '/{personID}/ips', { personID: 42 }, { recordStart: 0, pageSize: 10 });
// → '/42/ips?recordStart=0&pageSize=10'
buildUrl(this.baseUrl, this.listPath); // base + path only
qbo-json.ts
| Function | Description |
|---|---|
getObject(json) |
Parses a JSON string or passes through a plain object. Returns null for arrays, null, or unparseable input. |
getArray(json, arrayName?) |
Finds the first array anywhere inside a JSON value (recursing into nested objects) — handy for normalizing API responses of unknown shape into a list, as ExistsValidator does in the Form Validation section below. |
substitute(template, ...jsonData) |
Interpolates ${expr} placeholders (supports ${a.b} / ${a[0]} paths) against one or more data objects; unresolved expressions are stripped. Used internally by RestApiService.fetch to substitute values into apiEndpoint, and by QboFormElement (below) to substitute data into cloned template markup. |
resolveTemplate(path, vars) |
Substitutes {name} placeholders in a URL path, URI-encoding each value. Used by buildUrl. |
withSearchParams(baseUrl, params) |
Appends non-null/non-empty params as query parameters; returns a relative URL (pathname + search + hash), ready for fetch(). |
replicate(target, template, sourceData, emptyContent = true) |
Clones an HTML <template> once per item in sourceData into target, running substitute() over each clone's innerHTML. Optionally clears target first. |
resolveTemplate('/keys/{personID}/items', { personID: '42' });
// → '/keys/42/items'
withSearchParams('/api/keys', { recordStart: 0, displaySize: 25 });
// → '/api/keys?recordStart=0&displaySize=25'
Real-Time Updates (SignalR)
qbo-signalr-mixin.ts provides QboSignalRMixin, a Lit mixin that manages a SignalR HubConnection for a component — connecting, reconnecting, tearing down, and queuing event handlers — so individual components don't each reimplement hub lifecycle management. Reach for it whenever a component needs server-pushed updates (live counts, notifications, collaborative state) instead of polling. qbo-table composes this mixin directly (signalr-hub/signalr-event/signalr-mode attributes) to support live row updates.
Bundle cost: @microsoft/signalr is a regular (not peer) dependency, but it's imported lazily — await import('@microsoft/signalr') only runs the first time a component actually connects (signalrHub is set). Components that never use SignalR pay zero bundle cost for it.
Connection lifecycle
- Setting the
signalr-hubattribute /signalrHubproperty (a URL string) is what triggers a connection — nothing connects until it's set. connectedCallback()connects ifsignalrHubis already set at connect time.updated()watches for changes tosignalrHub: it disconnects the old connection and, once that resolves, connects to the new one. Simply changing the attribute is enough to point a live component at a different hub.disconnectedCallback()always tears the connection down (stop(), swallowing errors, then nulling the connection).- Internally, the connection is built with
HubConnectionBuilder().withUrl(hub).withAutomaticReconnect().build()— SignalR's built-in exponential-backoff reconnect is enabled by default; the mixin itself doesn't implement its own retry loop.
Pending-listener queue
Because connecting is asynchronous (dynamic import + conn.start()), calling onHubEvent() from connectedCallback() would otherwise race the connection. QboSignalRMixin handles this with a queue:
- If
onHubEvent(event, handler)is called before the connection exists, the{ event, handler }pair is queued instead of failing. - Once the connection successfully starts, every queued listener is registered on the live connection and the queue is cleared.
offHubEvent(event, handler?)removes from both places — the live connection (if connected) and any still-queued entries — so unregistering works correctly regardless of connection timing.
Practically: register your handlers unconditionally in connectedCallback(); you never need to check signalrConnected first or await a connected event before calling onHubEvent.
API surface
| Member | Type | Description |
|---|---|---|
signalrHub |
string | null (attr signalr-hub) |
Hub URL. Setting/changing it drives connect/reconnect. |
signalrConnected |
boolean (readonly getter) |
true only when the underlying connection's state is 'Connected'. |
onHubEvent(event, handler) |
method | Registers a server→client method handler. Safe to call before connection (queues). |
offHubEvent(event, handler?) |
method | Removes a handler from the live connection and/or pending queue. Omitting handler removes all handlers for that event. |
invokeHub(method, ...args) |
Promise<any> |
Invokes a hub method. Not queued — rejects immediately with Error('SignalR hub is not connected') if called before/without a live connection. |
Events
All are CustomEvents dispatched on the host element, bubbles: true, composed: true:
| Event | Fired when | detail |
|---|---|---|
qbo-signalr-connected |
Connection starts successfully | — |
qbo-signalr-disconnected |
SignalR's onclose fires (both explicit stop() and exhausted-reconnect closes) |
— |
qbo-signalr-reconnected |
An automatic-reconnect attempt succeeds | — |
qbo-signalr-error |
Connecting throws (dynamic import failure, conn.start() rejection, etc.) |
the thrown error |
There is no
qbo-signalr-reconnectingevent — the mixin only wires uponclose/onreconnected, not SignalR'sonreconnecting. If you need an in-between "reconnecting" UI state, extend the mixin or pollsignalrConnected.
Composition example
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { applyMixins, QboSignalRMixin, ISignalR } from '@quandis/qbo4.ui';
@customElement('live-queue')
export class LiveQueue extends applyMixins(QboSignalRMixin)(LitElement) {
declare signalrHub: ISignalR['signalrHub'];
declare onHubEvent: ISignalR['onHubEvent'];
declare invokeHub: ISignalR['invokeHub'];
@state() private pending = 0;
connectedCallback() {
super.connectedCallback();
this.onHubEvent('QueueUpdated', (count: number) => { this.pending = count; });
}
private refresh = () => this.invokeHub('RequestQueueSnapshot');
render() {
return html`<span>${this.pending} pending</span> <button @click=${this.refresh}>Refresh</button>`;
}
}
<live-queue signalr-hub="/hubs/queue"></live-queue>
Package Entry Point
Program.ts is the root module that @quandis/qbo4.ui's package entry points resolve to. It doesn't implement behavior itself — it:
- imports
reflect-metadata(required once, globally, for tsyringe's decorator-based DI to work), - augments global TypeScript types (
Window.qbo4,HTMLElement.attachInternals), - and re-exports every public module in the package — every
qbo-*component and mixin,Services,IApiService,RestApiService,IValidate/Validators, and the style modules.
It also defines a handful of small standalone helpers used across components: matches(source, pattern, wildcard = '*', ignoreCase = true) (simple wildcard matching), elementData(element) (an element's dataset as a plain Record<string, string>), and elementDate / elementDateTime (locale-agnostic date/datetime formatting for data-* attributes). Anything importable from '@quandis/qbo4.ui' is exported from here.
Api Endpoint Registration
If your web page needs to interface with multiple APIs, you can register them with the qbo-api component:
<qbo-api name="myService" method="POST" apiEndpoint="https://api.mysite.com/some/endpoint">
<header name="Accept">application/json</header>
</qbo-api>
<qbo-api name="addresses" method="GET" apiEndpoint="https://nominatim.openstreetmap.org/search?q=${value}&format=json">
<header name="Accept">application/xml</header>
</qbo-api>
Then, in typescript:
import { getApiService } from '@quandis/qbo4.ui';
const myApi = getApiService('myService');
const myResult = await myApi.fetch('/more/route/data', { Foo: 'Bar' });
// Alternatively, you can access and augment api endpoints with api://{name}/{path}, like so:
const myApiEndpoint = getApiService('api://myService/more/route/data');
// POST to https://api.mysite.com/some/endpoint/more/route/data
myApiEndpoint.fetch('', { Foo: 'Bar' });
const geoApi = getApiService('addresses');
const address = getApiService('geoApi').fetch('', { value: '1600 Pennsylvania Ave NW, Washington, DC' });
A few items to point out:
- The
IApiService.fetch(relativePath, json)can substitute against theapiEndpoint. This is useful for aGEToperation.- A
method="POST"will post thejsonobject as the body of the request.- If
methodis not defined, aGETwill be used ifjsonis null. Otherwise, aPOSTwill be used.- The
jsonobject can be a string, or an object. If it is an object, it will be stringified.- Additional headers can be specified in the
qbo-apicomponent.
Default API Endpoint
The qbo4-ui package will automatically register a default API endpoint using the window.location.
So, if your endpoints are on the same page as your web page, you can use the default API endpoint.
// This will point to the same website you are on.
const defaultApi = getApiService('default');
Reusing the same API with different paths
You may register an API, and then reuse it with different paths:
<qbo-api name="qms" method="POST" apiEndpoint="https://services.quandis.io/api/military/">
<header name="Accept">application/json</header>
</qbo-api>
Then, in typescript:
import { getApiService } from '@quandis/qbo4.ui';
const qmsSearch = getApiService('api://qms/scra/instant/{clientID}');
const qmsHealth = getApiService('api://qms/scra/healthcheck');
In this example, the qms is cloned in getApiService, and the relativePath is substituted into the apiEndpoint:
| API Syntax | Url Returned |
|---|---|
api://qms/scra/instant/{clientID} |
https://services.quandis.io/api/military/scra/instant/{clientID} |
api://qms/scra/healthcheck |
https://services.quandis.io/api/military/scra/healthcheck |
Custom API Services
You can write your own IApiService class, and register it to qbo's DI container:
import { injectable, InjectionToken } from 'tsyringe';
@injectable()
export class MyApi implements IApiService {
async fetch(relativePath: string | null, payload: Record<string, string> | null = null): Promise<any> {
// implement your own fetch logic here
}
}
// Register your service to qbo's DI container
services.container.registerInstance<IApiService>('myApi', new MyApi());
A few implementation details worth knowing (current source):
getApiServicealso accepts aURLinstance, not just a string — it readsurl.hrefbefore parsing theapi://scheme.- The
Acceptheader controls response parsing, not just content negotiation:RestApiService.fetchonly callsresponse.json()whenAcceptis exactlyapplication/json; any other value (including theapplication/xmlexample above) returns rawresponse.text()for you to parse yourself.<qbo-api>registers itself via an exportedregisterRestApi(name, apiEndpoint, headers?, method?)sugar function (fromRestApiService.ts). You can call it directly from TypeScript instead of declaring a<qbo-api>element if you're registering endpoints programmatically.
QboFormElement
The QboFormElement web component (<qbo-form-element>) wraps form elements and presents them to an enclosing <form> upon submission, prefixing every field's name with its own name — the standard way to reuse the same field markup multiple times in one form (e.g. a "primary" and "backup" address) without name collisions.
It's form-associated via QboFormMixin (see below), and gets its content one of two ways:
- A
templateattribute — theidof an external<template>element. On connect, the template's content is cloned into the element's own light DOM, thendata(if set) is substituted into the resulting markup via${expr}placeholders (seesubstitute()above). - Plain markup as direct children — no
template/dataneeded if you're authoring the fields inline.
<template id="address-fields">
<input type="text" name="Street" placeholder="Street" />
<input type="text" name="City" placeholder="City" />
</template>
<form id="fact">
<qbo-form-element name="primary" template="address-fields"></qbo-form-element>
<qbo-form-element name="backup" template="address-fields"></qbo-form-element>
</form>
Upon submission (or whenever a descendant field changes), the element's .value FormData — which qbo-form/native form submission reads — contains every input/select/textarea descendant, regardless of whether it came from the template or was authored directly as a child, each prefixed with ${this.name}:
{
"primaryStreet": "...",
"primaryCity": "...",
"backupStreet": "...",
"backupCity": "..."
}
Nested QboFormElements (or any QboFormMixin-based custom element) are supported too — their own .value entries are folded in with the outer element's name prefixed on top, so prefixes compose correctly at any nesting depth.
dataaccepts a plain object and is only applied once, at connect time, against the cloned template's HTML — it is not a live two-way binding. If you need to update field values after the initial render, set them on the underlying inputs directly (or re-render by changingtemplate).
QboFormMixin
QboFormMixin is the foundational building block behind every form-associated custom element in this package (qbo-form-element, qbo-select, qbo-typeahead, qbo-fieldset implements the same contract by hand). It is not a component — it's a Lit mixin that wires up ElementInternals so your custom element can participate in a native <form> submission just like a built-in <input>.
Reach for it when you're writing a new custom element that needs to contribute a value to an enclosing qbo-form (or any native <form>).
API surface
| Member | Type | Description |
|---|---|---|
name |
@property() string |
Default ''. Field name — also used as a namespace prefix by consumers like qbo-form-element. |
disabled |
@property({reflect:true}) boolean |
Default false. Mirrors the standard HTML disabled attribute. |
value |
FormData |
Default new FormData(). The component's current form value. |
form |
get form(): HTMLFormElement | null |
The owning <form>, resolved via ElementInternals.form. |
internals |
protected get internals(): ElementInternals |
Escape hatch for subclasses that need direct ElementInternals access (e.g. setValidity). |
setFormValue(data) |
protected (data: FormData) => void |
Sets this.value, calls internals.setFormValue(data), and dispatches qbo-form-update. |
static formAssociated = trueis set by the mixin's returned class. You do not need to declare it again on your subclass, but the class must be registered as a custom element (@customElement(...)) forformAssociatedto be honored by the browser.
setFormValue() always dispatches a composed, bubbling qbo-form-update CustomEvent with detail: { formData: data }. This is how sibling/parent components (like qbo-form-element's augment) observe value changes without polling.
Composition example
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { applyMixins, QboFormMixin } from '@quandis/qbo4.ui';
@customElement('qbo-rating')
export class QboRating extends applyMixins(QboFormMixin)(LitElement) {
// Mixin-provided members aren't visible to TS through applyMixins' type erasure —
// redeclare the ones you use so the compiler knows about them.
declare name: string;
declare protected setFormValue: (data: FormData) => void;
@property({ type: Number }) stars = 0;
private _select(n: number) {
this.stars = n;
const data = new FormData();
data.set(this.name, String(n));
this.setFormValue(data);
}
render() {
return html`${[1,2,3,4,5].map(n => html`
<span @click=${() => this._select(n)}>${n <= this.stars ? '★' : '☆'}</span>
`)}`;
}
}
<qbo-form action-url="/api/reviews">
<qbo-rating name="Rating"></qbo-rating>
<button type="submit">Submit</button>
</qbo-form>
QboForm
<qbo-form> is the workhorse container: it prefills fields from an API or inline JSON, intercepts submit, runs client-side validation, and posts the result — all without you writing any fetch/submit glue code. Reach for it any time you have a <form>-shaped UI backed by a REST endpoint.
Data flow
Prefill sources are checked in this order on connect (first one present wins):
| Order | Source | Trigger |
|---|---|---|
| 1 | fetch-src attribute |
GET request issued on connectedCallback() |
| 2 | data property |
Set programmatically; also re-applied whenever data changes after connect |
| 3 | data-selector attribute |
CSS selector resolving to an external <script type="application/json"> |
| 4 | <script type="application/json" slot="data"> child |
Inline JSON, no JavaScript required |
Fields are matched to the prefill object purely by the name attribute of input / select / textarea descendants (and by .value on nested form-associated custom elements, see below).
Submit is intercepted from any <button type="submit"> (or an untyped <button>, since type="submit" is the implicit default) inside the default slot. Before the request fires, every native input runs checkValidity()/reportValidity(), and every nested form-associated custom element's own validate() method (if it defines one) is called too.
Attributes
| Attribute | Default | Description |
|---|---|---|
fetch-src |
null |
GET URL for prefilling fields on connect. |
action-url |
null |
POST/PUT/PATCH URL for submission. Submit is a no-op (with a console warning) if unset. |
method |
'POST' |
HTTP method used for submission. |
success-message |
null |
Toast message shown after a successful submit. |
reset-on-success |
false |
Clears all fields after a successful submit. |
data-selector |
null |
CSS selector for an external prefill <script> element. |
qbo-class |
null |
CSS class(es) applied to the inner <form part="form"> element. |
warn-on-exit |
false |
Adds a beforeunload guard while the form is dirty. |
Events
| Event | Detail | Fired when |
|---|---|---|
qbo-form-loaded |
{ data } |
Prefill data has been applied to fields (from any of the four sources). |
qbo-form-submit |
— | Just before submission; cancelable via preventDefault() (useful for a dry-run/demo mode). |
qbo-form-success |
{ data } |
Submit succeeded; data is the parsed response body. |
qbo-form-error |
{ message } |
Submit failed. |
qbo-form-dirty / qbo-form-clean |
— | Fired when the payload diverges from (or returns to) the last-saved/prefilled baseline. Tracked automatically off input/change/qbo-form-update. |
Slots
| Slot | Purpose |
|---|---|
| (default) | Field markup: labels, inputs, selects, textareas, the submit button. |
loading |
Custom busy indicator, shown while prefilling or submitting. Defaults to "Loading…". |
data |
Hidden. Place a <script type="application/json"> here for inline prefill data. |
<qbo-form fetch-src="/api/contact/123"
action-url="/api/contact/123"
method="PUT"
success-message="Contact saved"
reset-on-success>
<label>Name</label>
<input name="Name" type="text" required />
<label>Email</label>
<input name="Email" type="email" required />
<button type="submit">Save</button>
<div slot="loading">Saving…</div>
</qbo-form>
form.addEventListener('qbo-form-success', () => modal.open = false);
form.addEventListener('qbo-form-dirty', () => dirtyBar.hidden = false);
Payload building understands checkboxes (grouped by
nameinto an array when more than one shares a name, otherwise a boolean), radios,number/range(coerced toNumberornull), multi-<select>(array of values), and merges in the.valueof any nested form-associated custom element (qbo-select,qbo-fieldset,qbo-form-element,qbo-typeahead, or your ownQboFormMixin-based element). Native inputs nested inside one of those custom elements are skipped fromqbo-form's own collection — the custom element owns them.
QboFieldset
<qbo-fieldset> groups related fields and layers three optional behaviors on top of a plain <fieldset>/<legend>: collapse/expand, conditional visibility driven by a sibling field, and repeating rows cloned from a <template>. It is always form-associated — qbo-form reads .value from it directly rather than reaching into its light DOM.
This component is recent/in-flight on
feature/UI-refactor— verify against the latest source before relying on edge-case behavior.
Attributes
| Attribute | Default | Description |
|---|---|---|
label |
null |
Legend text in the group header. |
collapsible |
false |
Renders a ▼/ toggle next to the legend. |
collapsed |
false |
Starts collapsed. A failed validation inside auto-expands the group so the browser tooltip is visible. |
show-if |
null |
"fieldName=value" (or just "fieldName" for non-empty) — watches a sibling field in the closest qbo-form. |
repeating |
false |
Clones a <template> child once per row; renders Add/Remove controls. |
name |
null |
Required when repeating — the key under which the row array is submitted. |
min-rows |
1 |
Minimum rows in repeating mode. The remove button hides at the minimum. |
max-rows |
10 |
Maximum rows in repeating mode. The add button hides at the maximum. |
Behavior notes
- Collapsible: all slotted fields still submit while collapsed — only visibility toggles (
fs-content[hidden]). - Conditional (
show-if): hidden groups have their inputsdisabled, so they're excluded from both validation and the submitted payload automatically. Re-checks onchange/inputon the parent form and again afterqbo-form-loaded(so prefilled data doesn't leave a group in the wrong state). - Repeating: row markup comes from a single
<template>child;.valuereturns an array of plain objects (one per row) rather thanFormData.
Slots
| Slot | Purpose |
|---|---|
| (default) | Field content, used in collapsible/conditional mode. Not used in repeating mode (rows come from the <template> instead). |
<qbo-form action-url="/api/save">
<input name="Name" required />
<qbo-fieldset label="Address" collapsible collapsed>
<input name="Street" /> <input name="City" />
</qbo-fieldset>
<qbo-fieldset label="Business info" show-if="Type=business">
<input name="CompanyName" required />
</qbo-fieldset>
<qbo-fieldset label="Contacts" repeating name="contacts" min-rows="1" max-rows="5">
<template>
<input name="Name" placeholder="Name" />
<input name="Email" type="email" placeholder="Email" />
</template>
</qbo-fieldset>
<button type="submit">Save</button>
</qbo-form>
Nesting works: a
qbo-selectorqbo-form-elementinside a collapsibleqbo-fieldsethas its own.valueFormData folded into the fieldset's aggregated value, so nothing double-counts whenqbo-formreads the fieldset in turn.
QboFormEdit
<qbo-form-edit> renders a standard "chrome" action bar that sits above a form: an Edit button, a History/Help dropdown, and an info popover showing audit timestamps (created/updated date + person). It is not itself a form — it's decoration and affordances that a page typically wires up to show/hide/enable the actual qbo-form beneath it.
Attributes
| Attribute | Default | Description |
|---|---|---|
type |
'defaultLayout' |
'defaultLayout' | 'createdLayout' | 'updatedLayout' — which audit fields the info popover shows. |
render-in-host |
true |
Render into light DOM (host element) instead of a shadow root, so Bootstrap classes on editLabel/buttonPrimaryClass/etc. apply globally. |
Nearly every visual piece (button classes, icon classes, labels) is exposed as an individually overridable @property, e.g. editLabel, historyText, helpIcon, infoTitle, divFormActionClass, ulDropdownClass — override any of them to reskin the bar without forking the component.
Data
Supply audit data either programmatically (data property, a plain object with CreatedDate/CreatedPerson/UpdatedDate/UpdatedPerson) or declaratively via a child <script type="application/json">, parsed on connect.
<qbo-form-edit type="defaultLayout">
<script type="application/json">
{"CreatedDate":"2024-01-15","CreatedPerson":"John Doe",
"UpdatedDate":"2024-03-20","UpdatedPerson":"Jane Smith"}
</script>
</qbo-form-edit>
editBar.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.qbo-primary')) {
form.querySelectorAll('input, select, textarea').forEach(el => el.disabled = false);
}
});
The History and Help menu items currently call
alert(...)placeholders (onclick="alert('Show History')") — wire up real handlers by listening for clicks on the bar and matching againsthistoryIcon/helpIconclasses, same pattern as the Edit button above.
QboSelect
<qbo-select> renders a native <select> whose <option>s come from a JSON API response, and participates in qbo-form like any other field via QboFormMixin.
Attributes
| Attribute/Property | Default | Description |
|---|---|---|
fetch-src / apiEndpoint |
— | URL to fetch option data from. Fetched automatically on connect (QboFetchMixin's fetchOnLoad defaults to true, unlike qbo-typeahead/qbo-datalist). |
option-text |
null |
Property name used as the visible option label. Falls back to the 2nd key of the first result object if unset. |
option-value |
null |
Property name used as <option value>. Falls back to the 1st key of the first result object if unset. |
default-value |
null |
Pre-selected value. Also set automatically from a qbo-form-loaded event on the closest qbo-form. |
empty-option-text |
'--' |
Label for the blank first option. |
empty-option-value |
'' |
Value for the blank first option. |
renderInHost |
true |
Renders the <select> into light DOM so page CSS/Bootstrap styles it directly. |
Events
Dispatches a plain, bubbling change event on selection (in addition to the mixin's qbo-form-update), so existing addEventListener('change', ...) code keeps working.
<qbo-select name="Status"
apiEndpoint="/api/status-options.json"
optionValue="value"
optionText="label"
emptyOptionText="— select —"></qbo-select>
Prefer
fetch-src/apiEndpointover hand-building<option>s — the empty option is always injected first, anddefaultValuereconciliation happens automatically once bothjsonDataand the form's prefill have arrived, regardless of which resolves first.
QboTypeahead
<qbo-typeahead> is an autocomplete input with API-backed suggestions, a self-rendered dropdown (shadow DOM, styled), and full form association. Reach for it instead of qbo-datalist when you want a styled, keyboard-navigable suggestion list rather than the native <datalist> popup — and when you need the field to participate in qbo-form dirty tracking.
Attributes
| Attribute | Default | Description |
|---|---|---|
name |
— | Form field name; becomes the FormData key. |
apiendpoint |
— | URL template; {value} is replaced with the typed (URL-encoded) text. |
option-text |
null |
JSON key used as the visible option label. |
option-value |
null |
JSON key used as the submitted form value. |
min |
2 |
Minimum characters typed before suggestions are fetched. |
placeholder |
'' |
Input placeholder text. |
default-text |
null |
Initial visible text. |
default-value |
null |
Initial submitted value (hidden until resolved against options). |
debounce |
300 |
Milliseconds after the last keystroke before fetching. |
filter-local |
false |
Fetch the option list once, then filter client-side on every keystroke — use with static/small JSON endpoints that can't filter server-side. |
disabled |
false |
Mirrors the native disabled attribute. |
Events
Fires qbo-form-update (via QboFormMixin) whenever the selected value changes — no separate custom event.
Keyboard support
Arrow Up/Down move the active suggestion, Enter selects it, Escape closes the dropdown. Clicking outside the component (via composedPath()) also closes it.
<qbo-typeahead
name="ContactId"
apiendpoint="/api/contacts-typeahead?q={value}"
option-text="name"
option-value="id"
placeholder="Search contacts…"
filter-local
></qbo-typeahead>
Inside a form, prefill resolves the display text against the fetched (or cached, for filter-local) options so the input shows a name rather than a raw id:
<qbo-form action-url="/api/cases/1">
<script type="application/json" slot="data">{"AssignedTo":"3"}</script>
<qbo-typeahead name="AssignedTo" apiendpoint="/api/contacts-typeahead?q={value}"
option-text="name" option-value="id" filter-local></qbo-typeahead>
</qbo-form>
filter-localfetches the unfiltered list once ({value}replaced with an empty string) and caches it in memory; every keystroke after that only filters, it doesn't re-fetch. Don't usefilter-localagainst a large or per-user-scoped endpoint.
QboDatalist
<qbo-datalist> wraps the native HTML <datalist> element, populating its <option>s from a JSON API response as the user types into an associated <input list="...">. It is the lighter-weight, no-shadow-DOM alternative to qbo-typeahead — use it when native browser autocomplete UX is good enough and you don't need form-association or a styled dropdown.
Attributes
| Attribute/Property | Default | Description |
|---|---|---|
listId |
null |
Matches the list attribute of the target <input list="..."> element(s) it wires up. |
min |
3 |
Minimum characters typed (on keyup) before a fetch is triggered. |
text |
null |
Property name used for the visible <option value>. Falls back to the 2nd key of the first result. |
value |
null |
Property name used for data-value on each <option>. Falls back to the 1st key of the first result. |
renderInHost |
true |
Renders the <datalist> into light DOM. |
fetchOnLoad |
false |
Disabled by default — fetching is user-input-driven (keyup), not connect-time. |
Companion behavior: data-for
If the target <input list="..."> also has a data-for="#otherInput" attribute, qbo-datalist listens for change on it and, once the typed text exactly matches one option's text, copies that option's value into the referenced element. This is the common "type a label, populate a hidden id" pattern.
<input list="cities" data-for="#cityId" name="cityLabel" />
<input type="hidden" id="cityId" name="cityId" />
<qbo-datalist listId="cities" apiEndpoint="/api/cities?q={value}"
text="name" value="id"></qbo-datalist>
Unlike
qbo-selectandqbo-typeahead,qbo-datalistis not form-associated — it only populates the datalist and (optionally) the paired hidden field. The visible<input>you pointlistat is what actually gets submitted.
QboSelectable
<qbo-selectable> adds click-to-select/multi-select behavior (with shift-range and ctrl-toggle support) to a set of sibling or descendant elements, without requiring them to be form fields. Reach for it for list/grid row selection (e.g. a table of records with a "select all" checkbox), not for form input collection.
Attributes/Properties
| Attribute | Default | Description |
|---|---|---|
selector |
'*[data-selectable]' |
Selector used to find selectable items within target. |
parent |
null |
Selector for an ancestor to use as target (via closest()) instead of the component itself. |
classSelected |
'qbo-select-on' |
Class applied to a selected item. |
classUnselected |
'qbo-select-off' |
Class applied to a deselected item. |
mode |
Mode.Single |
Mode.Single (click replaces selection unless shift/ctrl held) or Mode.Multi. |
toggleCheckbox |
true |
Also syncs a nested/matching input[type=checkbox] on each item. |
selected |
false |
"Select all" toggle state, driven by clicking the component's own slot content. |
Events
Fires a plain change CustomEvent (bubbling, composed) with detail: { items: HTMLElement[], values: string[] } (values pulled from each item's data-selectable attribute) whenever the selection changes.
Slots
| Slot | Purpose |
|---|---|
| (default) | Content for the "select all" control itself. Defaults to a qbo-icon checkbox glyph if left empty. |
<qbo-selectable parent=".record-list" mode="multi"></qbo-selectable>
<div class="record-list">
<div data-selectable="101">Row 1 <input type="checkbox" /></div>
<div data-selectable="102">Row 2 <input type="checkbox" /></div>
</div>
document.querySelector('qbo-selectable')!
.addEventListener('change', (e: any) => console.log(e.detail.values));
qbo-selectableis a selection-state utility, not a form participant — it does not implementQboFormMixinand has no.value/nameforqbo-formto read. If you need the selection to submit with a form, reade.detail.valuesyourself and write it into a hidden input.
QboFileUpload
<qbo-file-upload> is a self-contained drag-and-drop / click-to-browse file picker with a selected-file list, per-file size display, and an optional max-size guard. Reach for it any time you need file selection UI nicer than a bare <input type="file">.
Attributes
| Attribute | Default | Description |
|---|---|---|
accept |
'' |
Passed straight through to the underlying <input type="file" accept="...">. |
multiple |
false |
Allow selecting/dropping more than one file. |
max-size |
0 |
Maximum file size in bytes; 0 means no limit. Oversized files are rejected individually with an inline error, not silently dropped from a valid batch. |
Events
| Event | Detail | Fired when |
|---|---|---|
qbo-file-select |
{ files: File[] } |
The selected-file set changes — after adding via click/drop, or after removing one via its ✕ button. |
<qbo-file-upload accept="image/png,image/jpeg" multiple max-size="5242880"></qbo-file-upload>
document.querySelector('qbo-file-upload')!
.addEventListener('qbo-file-select', (e: any) => {
const files: File[] = e.detail.files;
// build a FormData / upload however your API expects it
});
qbo-file-uploadis not form-associated — it does not implementQboFormMixin, soqbo-form's automatic payload collection won't pick it up. Listen forqbo-file-selectand handle the upload (or attach the files to your own submit payload) yourself, e.g. inside aqbo-form-submithandler.
Form Validation
Modern browers support a very rich set of form valiation functionality; use it! Custom functionality can be introduced as follows:
<form>
<qbo-validate></qbo-validate>
<div>
<label for="city">City</label>
<input type="text" name="address" required data-exists="addresses">
</div>
<button type="submit">Submit form</button>
</form>
Note that the
data-existstag has a value ofaddresses, which corresponds to theqbo-apiname attribute.
This markup, combined with our custom ExistsValidator class, will validate that the value entered in the input field exists in the addresses API.
Here is our ExistsValidator class:
import { injectable, InjectionToken } from 'tsyringe';
@injectable()
export class ExistsValidator implements IValidate {
async validate(input: HTMLElement): Promise<boolean> {
var url = input.getAttribute('data-exists');
if (!url) return false;
var path = input.getAttribute('data-exists-path');
const service: IApiService = container.isRegistered(url) ? container.resolve<IApiService>(url) : new RestApiService(url);
if (input instanceof HTMLInputElement || input instanceof HTMLSelectElement || input instanceof HTMLTextAreaElement) {
const response = await service.fetch(path, { value: input.value });
const json = getArray(response);
if (json == null)
return false;
return json.length > 0;
}
return false;
};
message = 'This value does not appear to exist.';
selector = '[data-exists]';
}
container.register(ValidateToken, { useClass: ExistsValidator });
The IValidate interface is:
export interface IValidate {
// Called by `QboValidate` when fields change or a form is submitted.
validate(input: HTMLElement): Promise<boolean>;
// Called by `QboValidate` when the component is connected to the DOM.
connect(form: HTMLFormElement): void { };
// Called by `QboValidate` when the component is disconnected from the DOM.
disconnect(): void { };
// Message to display when validation fails.
message: string;
// Selector to use to find elements to apply the IValidate implementation to.
selector: string;
}
Paired with the qbo-validate component, you can decorate your HTML markup with simple attributes,
and automatically trigger form validate against any IValidate class registered with the DI container.
It's important that your selector be unique amont all registered IValidate classes.
We recommend the selector be [data-{IValidate class name}] for consistency.
Dependencies
It's common for form controls (and other UI components) to depend on the values of each other.
For example:
<input type="text" name="This" placeholder="Enter something here" />
<input type="text" name="That" placeholder="Or something here" />
<input type="text" name="Other" placeholder="To enable this" data-depend-options='{"depends": "This,That"}'/>
In this example, the Other field will only be enabled if either the This or That field have a value.
DependValidator Options
| Option | Default | Description |
|---|---|---|
depends |
undefined |
A comma-delimited list of field names (or ids) that the element is dependent upon. |
condition |
or |
If or, just 1 dependency must be met. If and, every dependency must be met. |
emptyOnDisable |
false |
If true, dependent values will be set to empty if dependencies are not met. |
resetOnDisable |
true |
If true, dependent values will be reset to their original value if dependencies are not met. |
disabledClass |
disabled |
Css class to apply to an element if dependencies are not met. |
disableChildren |
true |
If true, all child elements will be disabled if dependencies is not met. |
Depends examples
| Example | Description |
|---|---|
{"depends": "This,That"} |
Either This or That must have a non-empty value. |
{"depends": "This,That", "condition": "and"} |
Both This and That must have a non-empty value. |
{"depends": "This=Foo"} |
This must have a value Foo. |
{"depends": "This!=Foo"} |
This must not have a value Foo. |
{"depends": "This="} |
This must have an empty value. |
{"depends": "!This"} |
This must have an empty value. |
{"depends": "This=Foo*"} |
This must have a value that starts with Foo. |
{"depends": "This=*Bar"} |
This must have a value that ends with Bar. |
{"depends": "This=*oo*"} |
This must have a value that contains oo. |
The depends attribute may reference elements by id or by name.
In the case of a conflict, the id will be used.
The following expression is used find the target element:
const target = this.form.querySelector(`#${CSS.escape(selector)}`)
?? this.form.querySelector(`[name="${CSS.escape(selector)}"]`);
HTML markup and browser standards require that attributes containing JSON be double-quoted:
<input type="text" data-depend-options='{"depends": "This"}'/>
is valid, but the following is not:
<input type="text" data-depend-options="{'depends': 'This'}"/>
Implementation notes (verified against source)
The IValidate interface and the DependValidator Options/Depends examples tables above are accurate as written — DependencyValidatorOptions defaults (condition: 'or', emptyOnDisable: false, resetOnDisable: true, disabledClass: 'disabled', disableChildren: true) match the table exactly, and the operator/wildcard parsing (=, !=, >, <, >=, <=, plus * as a wildcard) matches every example except one.
{"depends": "!This"}does not currently work. The!shorthand is dead code:getOptions()computesoperator = operationsKeys.find(...) ?? '!=', which always evaluates to the truthy'!='fallback, so theelse if (depend.startsWith('!'))branch intended to handle!Thiscan never execute. In practice!Thisis parsed with selector"!This"and operator!=, which won't resolve to a real form field. Use the explicit form (This=) to require an empty value instead.
Validators.ts also registers two built-in validators not shown above: ZipCodeValidator (selector .zipcode) and EmailValidator (selector .email). Both currently always resolve true — they're registered scaffolding for future zip/email format checks, not functioning validators yet.
qbo-validate.ts itself has surface not documented above: it fires qbo-form-validated (on the form, after full-form validation) and qbo-input-validated (on each input, after per-field validation), and exposes autoComplete (default 'off'), validatedClass (default 'was-validated', added to the form once validated), and workingClass (default ['bg-light', 'text-secondary'], toggled on an input while its async validator is pending).
Data Tables
qbo-table
<qbo-table> renders tabular data with configurable columns, optional row selection, per-row action buttons, and optional real-time updates over SignalR. Reach for it any time you need a data grid driven by an API, a <script type="application/json"> payload, or plain property bindings from Lit.
Data can arrive from three sources, checked in priority order:
rowsset directly (.rows = data, or via thedataslot).apiEndpoint— fetched automatically viaQboFetchMixin.dataSelector— a CSS selector pointing at a<script type="application/json">element elsewhere in the page.
When columns is omitted, headers are auto-derived from the keys of the first row object, so a basic table needs no configuration at all.
| Attribute | Type | Default | Description |
|---|---|---|---|
fetch-src |
string |
— | URL to fetch row data from on load |
qbo-class |
string |
table table-striped table-hover |
CSS classes applied to the <table> element |
selectable |
boolean |
false |
Adds a leading checkbox column and multi-select |
data-selector |
string |
null |
CSS selector for a <script type="application/json"> element supplying rows |
signalr-hub |
string |
— | SignalR hub URL; connects automatically when set (from QboSignalRMixin) |
signalr-event |
string |
null |
Hub event name that delivers row updates |
signalr-mode |
'replace'|'append'|'patch' |
replace |
How incoming SignalR rows merge with existing rows |
signalr-key |
string |
id |
Row identity field used by patch mode |
renderInHost |
boolean |
false |
Render into the light DOM instead of a shadow root |
loading |
boolean |
false |
Shows a loading row while data is fetched |
rows |
any[] |
[] |
Row objects (property only) |
columns |
ColumnDef[] |
[] |
Column definitions (property only) |
rowActions |
RowAction[] |
[] |
Row action buttons (property only) |
ColumnDef fields:
| Field | Type | Description |
|---|---|---|
key |
string |
Property name read from each row object |
label |
string |
Header text; defaults to key |
width |
string |
CSS width applied to the <th> |
align |
'left'|'center'|'right' |
Horizontal text alignment |
hidden |
boolean |
Excludes the column from rendering without removing it from the array |
format |
(value, row) => any |
Transforms the raw cell value; may return a string, number, or Lit TemplateResult |
RowAction fields: id (echoed in the event detail), label, variant (CSS class, defaults to ghost), title (tooltip).
SignalRMode values: replace (swap the whole array), append (add to the end), patch (update rows matching signalr-key, appending unmatched rows).
Slots: header (full-width area above the column headers, hidden when empty), footer (defaults to a row-count summary), empty (shown when rows is empty and not loading), and three hidden slots — data, columns, row-actions — for setting rows/columns/actions from a <script type="application/json"> child without JavaScript.
Events: qbo-table-select ({ rows, indices }), qbo-table-row-click ({ row, index }), qbo-table-row-action ({ id, row, index }), qbo-table-updated, qbo-table-error.
<qbo-table apiEndpoint="api://myService/items" selectable></qbo-table>
const table = document.querySelector('qbo-table')!;
table.columns = [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status', format: v => v ?? '—' },
];
table.rowActions = [{ id: 'delete', label: 'Delete', variant: 'danger' }];
table.addEventListener('qbo-table-row-action', e => {
if (e.detail.id === 'delete') removeItem(e.detail.row);
});
renderInHostis used internally by<qbo-table-tabs>so that a single shared<qbo-table>picks up host-level CSS. Set it yourself if you need the table's markup to participate in the light DOM (e.g. for external CSS selectors or print stylesheets).
qbo-table-sort
<qbo-table-sort> is a headless helper you drop inside (or alongside) a <table> to add click-to-sort behavior on <th> elements, without touching <qbo-table> itself. It renders nothing — on firstUpdated it walks up to the closest <table> ancestor and attaches a click listener.
Clicking a <th data-sort="..."> toggles direction and, unless type="remote", re-sorts the <tbody> rows in place using each cell's data-sort-value attribute (falling back to trimmed text content). Sort comparison depends on the data-sort attribute value pattern (numeric, date, or default string comparison via localeCompare).
| Property | Type | Default | Description |
|---|---|---|---|
direction |
'asc'|'desc' |
desc |
Current sort direction; toggles on each click |
selector |
string |
table |
Reserved for future selector-based targeting |
trigger |
'ctrl'|null |
null |
Reserved modifier-key trigger |
type |
'inline'|'remote'|'all' |
inline |
inline sorts rows in the DOM; remote skips DOM sorting and only fires the event (for server-side sorting) |
Event: sort — { sortBy, direction, source }, where sortBy is the clicked column's data-sort value (or header text) and source is the originating MouseEvent.
<table>
<qbo-table-sort type="remote"></qbo-table-sort>
<thead>
<tr>
<th data-sort="name">Name</th>
<th data-sort="createdAt">Created</th>
</tr>
</thead>
<tbody>...</tbody>
</table>
document.querySelector('table')!.addEventListener('sort', (e: CustomEvent) => {
const { sortBy, direction } = e.detail;
loadPage({ sortBy, direction }); // server-side sort
});
With
type="inline"(the default), no server round-trip is needed — sorting happens entirely against the existing<tbody>rows.
qbo-table-tabs
<qbo-table-tabs> renders a tab bar backed by a single shared <qbo-table> — switching tabs swaps the active tab's rows/columns/actions into that one table rather than mounting a separate table per tab. Use it when you have several related datasets (e.g. "Users" / "Projects") that share the same table styling and don't need to render simultaneously.
All qbo-table-* events (qbo-table-select, qbo-table-row-click, qbo-table-row-action) bubble straight through — listen on <qbo-table-tabs> itself rather than reaching into its shadow root.
| Property | Type | Default | Description |
|---|---|---|---|
tabs |
TableTabDef[] |
[] |
Tab definitions; setting this replaces all tabs and resets to tab 0 |
TableTabDef fields:
| Field | Type | Description |
|---|---|---|
label |
string |
Text shown on the tab button |
rows |
any[] |
Row data for this tab |
columns |
ColumnDef[] |
Column definitions; auto-derived from rows[0] keys when omitted |
selectable |
boolean |
Show a leading checkbox column on this tab's table |
rowActions |
RowAction[] |
Per-row action buttons for this tab's table |
apiEndpoint |
string |
Remote endpoint, fetched via QboFetchMixin when set |
dataSelector |
string |
CSS selector for a <script type="application/json"> data element |
qboClass |
string |
CSS classes applied to the inner <table> element |
loading |
boolean |
Show a loading indicator row while data is being fetched |
Event: qbo-table-tabs-change — { index, label }, fired when the active tab changes.
<qbo-table-tabs id="tabs"></qbo-table-tabs>
const tabs = document.getElementById('tabs') as any;
tabs.tabs = [
{ label: 'Users', rows: usersData, columns: userCols },
{ label: 'Projects', rows: projectsData, columns: projectCols },
];
Pagination
qbo-paginate
<qbo-paginate> renders a Bootstrap-style pager (previous/next, numbered pages with ... spacers, and an optional page-size input). It's presentation-only — it doesn't fetch or slice data itself; it emits a change event with the new offset/size and expects the parent to reload accordingly.
| Property | Type | Default | Description |
|---|---|---|---|
display |
number |
25 |
Rows shown per page |
start |
number |
0 |
Current record offset |
count |
number |
0 |
Total record count, used to compute the max page |
pageLimit |
number |
10 |
Max number of page-number links shown at once (rolling window) |
currentPage |
number |
1 |
Currently active page (1-based) |
rollingView |
boolean |
true |
Reserved for rolling-window page display |
editSize |
boolean |
true |
Shows the "Results per page" input when true |
displayCountText |
string |
Results per page: |
Label text next to the page-size input |
Event: change — { start, display, page }, fired whenever the page or page size changes.
<qbo-paginate .count=${totalCount} .display=${pageSize} @change=${onPageChange}></qbo-paginate>
function onPageChange(e: CustomEvent<{ start: number; display: number }>) {
recordStart = e.detail.start;
pageSize = e.detail.display;
loadPage();
}
QboPaginatedListMixin
A mixin — not a custom element — that adds pagination state (recordStart, pageSize, totalCount) to any Lit component and wires it to <qbo-paginate>'s change event, eliminating repetitive pagination boilerplate in list components.
| Member | Type | Description |
|---|---|---|
recordStart |
number (state, default 0) |
Current record offset |
pageSize |
number (state, default 25) |
Records per page |
totalCount |
number (state, default 0) |
Total record count |
startParam |
string (protected, default recordStart) |
Query param name for the start offset; override to match your API |
sizeParam |
string (protected, default displaySize) |
Query param name for page size; override to match your API |
paginatedParams |
Record<string, number> (getter) |
Returns { [startParam]: recordStart, [sizeParam]: pageSize }, ready to spread into buildUrl or withSearchParams |
onPageChange(e) |
method | Handles <qbo-paginate>'s change event; updates recordStart/pageSize |
resetPagination() |
method | Resets to page 1 without triggering a fetch |
Since recordStart/pageSize are reactive @state properties, changing them triggers Lit's update cycle — implement updated() to reload data:
class MyList extends QboPaginatedListMixin(QboFetchStateMixin(QboToastMixin(LitElement))) {
async load() {
const url = buildUrl(this.baseUrl, this.listPath, {}, this.paginatedParams);
const result = await fetchJsonWithState<PagedResult>(url, { handlers: this.fetchHandlers });
if (result) {
this.items = result.items;
this.totalCount = result.totalCount;
}
}
render() {
return html`
${this.items.map(i => html`...`)}
<qbo-paginate .count=${this.totalCount} .display=${this.pageSize} @change=${this.onPageChange}></qbo-paginate>
`;
}
updated(changed: Map<string, unknown>) {
if (changed.has('recordStart') || changed.has('pageSize')) this.load();
}
}
Override
startParam/sizeParamif your API doesn't use therecordStart/displaySizeconvention used across the Quandis ecosystem.
QboCard
<qbo-card> is a display-state container supporting normal / minimize / maximize transitions, driven either by clicking a trigger element inside it or by document-level custom events. It renders in the light DOM by default, so styling is done with CSS custom properties rather than ::part() selectors.
| Property | Type | Default | Description |
|---|---|---|---|
display |
'normal'|'minimize'|'maximize' |
normal |
Current display state; reflected as an attribute and mirrored onto classList |
trigger |
string |
header |
CSS selector (relative to the card) for the element whose click toggles state |
renderInHost |
boolean |
true |
Render into the light DOM instead of a shadow root |
columns |
number |
undefined |
Optional column count, consumed by CSS |
CSS custom properties: --qbo-card-border-radius, --qbo-card-padding, --qbo-card-background, --qbo-card-min-height (height while minimized).
Click behavior: clicking the trigger element calls grow(); Ctrl+click calls shrink(). If the card has no .expanded child content on connect, it starts maximized instead of normal.
The card also listens for document-level events so other components can control it remotely: qbo-card-minimize, qbo-card-normal, qbo-card-maximize (each takes it to the matching state). It fires the same three event names on itself when transitioning.
Slot: default slot for card content, including an optional .expanded element shown only in the normal state.
<qbo-card trigger="header">
<div class="header">Account Summary</div>
<div class="expanded">Full details shown in normal state…</div>
</qbo-card>
QboStatusBadge
<qbo-status-badge> is a small Bootstrap-style badge for showing a status label with semantic coloring — use it in table cells, cards, or lists wherever you need a compact status indicator.
| Property | Type | Default | Description |
|---|---|---|---|
type |
string |
primary |
One of success, danger, warning, info, primary, secondary, light, dark — controls background/text color |
pill |
boolean |
false |
Renders fully rounded (pill) corners |
Slot: default slot for the badge label/content.
<qbo-status-badge type="success" pill>Active</qbo-status-badge>
<qbo-status-badge type="danger">Failed</qbo-status-badge>
QboIcon
<qbo-icon> renders an SVG icon by reference to a <symbol id> in the bundled qbo-icons.svg sprite sheet (Bootstrap Icons plus a set of qbo-specific symbols). It resolves the sprite URL automatically from a <meta name="qbo4-basepath"> tag, so no per-page imagePath configuration is needed.
| Attribute | Type | Default | Description |
|---|---|---|---|
icon |
string |
null |
Icon identifier — a logical name (see table below) or a raw sprite symbol id |
type |
string |
icon |
icon/button render statically; toggle enables selected/deselected click state; standard/all render a gallery of available icons (dev/debug use) |
sprite |
string |
— | Overrides the sprite URL; defaults to {basepath}/ui/images/qbo-icons.svg |
height |
string |
1rem |
SVG height |
width |
string |
1rem |
SVG width |
fill |
string |
currentColor |
SVG fill color |
disabled |
boolean |
false |
Disables pointer events and reduces opacity |
selected |
boolean |
false |
Reflects selected state when type="toggle"; also drives aria-pressed |
renderInHost |
boolean |
true |
Render into the light DOM instead of a shadow root |
Events (only for type="toggle"): selected, deselected — fired when the toggle state flips.
Built-in logical icon names (QboIcon.map), mapped to sprite symbol ids:
| Logical name | Symbol id | Logical name | Symbol id |
|---|---|---|---|
accounting |
currency-dollar |
refresh |
refresh |
collection |
tag |
cut |
cut |
document |
file-earmark |
copy |
copy |
export |
database-down |
paste |
paste |
import |
database-up |
delete |
delete |
message |
envelope |
down |
south |
process |
gear |
right |
east |
score |
file-spreadsheet |
maximize |
southeast |
task |
list-check |
minimize |
dash |
workflow |
diagram-2 |
normal |
summary |
security |
lock |
upload / download |
upload / download |
folder / folder-open |
folder2-open |
pdf |
document-pdf |
warning |
flag |
<qbo-icon icon="delete" type="toggle" @selected=${onSelect}></qbo-icon>
For use inside a component's render() (no imagePath boilerplate needed), use the exported iconTemplate helper instead of the element:
import { iconTemplate } from '@quandis/qbo4.ui';
render() {
return html`<button>${iconTemplate('plus')} Add</button>`;
}
Any raw Bootstrap Icons symbol id also works directly as the
iconvalue — the logical names in the table above are just convenient aliases.
QboLoadingSpinner
<qbo-loading-spinner> is an animated loading indicator — either a spinning border ring or a growing/pulsing dot — with an accessible role="status" label for screen readers.
| Attribute | Type | Default | Description |
|---|---|---|---|
type |
'border'|'grow' |
border |
Animation style |
small |
boolean |
false |
Renders a smaller variant |
overlay |
boolean |
false |
Positions the spinner as an absolute, centered overlay over its parent (reflects to attribute for :host([overlay]) styling) |
label |
string |
Loading… |
Accessible label read by screen readers |
<div style="position: relative;">
<qbo-loading-spinner overlay label="Loading results…"></qbo-loading-spinner>
...
</div>
QboLoadingMixin
A mixin that adds a loading state plus a single-flight async guard, eliminating the repetitive _fetchToken / _xxxPromise / fetchXxxSingleFlight() pattern that shows up in every data-fetching component. Compose it with other mixins via applyMixins().
| Member | Type | Description |
|---|---|---|
loading |
boolean (state, default false) |
Reactive loading flag |
singleFlight(fn) |
(token: number) => Promise<void> → Promise<void> |
Runs fn as a single-flight operation — a new call supersedes any call still in progress rather than queuing behind it |
isStale(token) |
(token: number) => boolean |
Returns true if a newer singleFlight call has been issued since token was handed out; use to bail out of in-flight async work early |
class MyEl extends applyMixins(QboFetchMixin, QboLoadingMixin)(LitElement) {
async load() {
this.loading = true;
try {
await this.singleFlight(async (token) => {
const data = await fetch(url).then(r => r.json());
if (this.isStale(token)) return; // a newer call superseded this one
this.items = data;
});
} finally {
this.loading = false;
}
}
}
Pair
QboLoadingMixin'sloadingstate directly with<qbo-loading-spinner overlay>or<qbo-table loading>to drive a consistent loading UI without hand-rolled flags.
Modals & Confirmation Dialogs
QboModal
<qbo-modal> is a general-purpose modal dialog with an overlay, header, a default body slot, and an actions footer slot for buttons. It handles its own overlay-click and Escape-key dismissal (via the internal QboKeyboardMixin) but does not manage its own open state — closing is always delegated back to the consumer through the qbo-modal-close event.
| Attribute | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Shows or hides the modal. Reflected to the attribute. |
heading |
string |
'' |
Header text, also used as the dialog's aria-label. |
close-disabled |
boolean |
false |
Disables the close button and suppresses qbo-modal-close from the button and overlay. Use when the user must acknowledge content before dismissing (e.g. a one-time secret). |
Events
| Event | Cancellable | Description |
|---|---|---|
qbo-modal-close |
Yes | Fired when the user clicks the close button or clicks the overlay backdrop. The consumer is responsible for setting open = false in the handler — QboModal never closes itself. |
Slots
| Slot | Description |
|---|---|
| (default) | Body content. Wrap in <div class="form-list"> for standard form layout, <div class="form-item"> for label+input pairs, <span class="field-error"> for inline validation, and `<span class="status-badge positive |
actions |
Footer buttons, rendered inside a .actions div. |
<qbo-modal ?open=${this.open} heading="Edit Record" @qbo-modal-close=${() => this.open = false}>
<div class="form-list">
<div class="form-item"><label>Name</label><input .value=${x} /></div>
</div>
<button slot="actions" class="ghost" @click=${() => this.open = false}>Cancel</button>
<button slot="actions" @click=${this.save}>Save</button>
</qbo-modal>
QboModalrenders nothing whenopenisfalserather than hiding via CSS, so it never occupies layout space while closed.
QboConfirm
<qbo-confirm> is a drop-in replacement for the browser's confirm(), built directly on top of <qbo-modal> — it wraps a qbo-modal internally and forwards its qbo-modal-close to a cancel action. Reach for it instead of a raw confirm() call whenever you need consistent styling, a danger variant for destructive actions, and keyboard support.
| Attribute | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Controls visibility. |
heading |
string |
'Confirm' |
Dialog header text. |
message |
string |
'' |
Body message shown to the user. |
confirm-label |
string |
'Confirm' |
Label for the confirm button. |
cancel-label |
string |
'Cancel' |
Label for the cancel button. |
danger |
boolean |
false |
Renders the confirm button with the danger (red) class. Use for destructive actions like delete. |
Events
| Event | Description |
|---|---|
qbo-confirmed |
User clicked the confirm button. |
qbo-cancelled |
User clicked cancel, the close button, or the overlay. |
<qbo-confirm
?open=${this.confirmOpen}
heading="Delete entry"
message="This cannot be undone. Are you sure?"
danger
confirm-label="Delete"
@qbo-confirmed=${this.handleDelete}
@qbo-cancelled=${() => this.confirmOpen = false}>
</qbo-confirm>
private onDeleteClick() {
this.confirmOpen = true;
}
private async handleDelete() {
this.confirmOpen = false;
await sendJsonWithState(this.deleteUrl, null, { method: 'DELETE', handlers: this.fetchHandlers });
}
Popups, Popovers, and Menus
qbo-popup,qbo-popup-listener,qbo-popover,qbo-menu, andqbo-contextmenusolve overlapping problems (toggling floating content off a trigger) but are independent implementations — none of them import or build on another. Pick the one whose trigger/positioning model matches your case rather than assuming they're interchangeable.
QboPopup
<qbo-popup> toggles a content slot open and closed from a button slot, and can optionally fetch its body HTML from an API endpoint via the composed QboFetchMixin. Use it for simple, inline popups anchored to their own trigger markup — it does not do floating-UI positioning; the content is just shown/hidden in place.
| Property | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Whether the content slot is visible. |
loading |
boolean |
false |
Present for consumers to key loading UI off of; not currently toggled internally. |
renderInHost |
boolean |
false |
Reserved for rendering content in the light DOM instead of the shadow root. |
apiEndpoint, method, html, jsonData, ... |
— | — | Inherited from QboFetchMixin — set apiEndpoint to fetch HTML content automatically on connect. |
Slots
| Slot | Description |
|---|---|
button |
Trigger element. A click toggles open. Defaults to a <button class="btn">Popup</button> if empty. |
content |
Popup body. When apiEndpoint is set, the fetched HTML is rendered here via unsafeHTML. |
<qbo-popup apiEndpoint="api://default/help/summary">
<button slot="button" class="btn btn-secondary">Help</button>
<div slot="content">Fallback content while loading…</div>
</qbo-popup>
A document-level
clicklistener is attached whileopenistrueso clicking outside the component closes it; clicking anything insidecontentwithdata-dismiss="modal"closes it explicitly.
QboPopupListener
<qbo-popup-listener> is a different popup mechanism purpose-built for showing embedded media (notably YouTube) in a modal iframe, triggered by clicking any link on the page rather than a fixed button. It listens on a target element (or document by default) for click events, hijacks the first <a> in the event's composed path, and re-dispatches a configurable custom event (eventName, default qbo-popup) carrying that anchor — which then sets the iframe's src (rewriting youtube.com/watch to youtube.com/embed) and opens the modal.
| Property | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Whether the modal/iframe container is visible. |
loading |
boolean |
false |
Present for consumer use; not toggled internally. |
renderInHost |
boolean |
false |
Reserved, unused internally. |
src |
string |
undefined |
Current iframe src. Set automatically from the triggering link's href. |
title |
string |
'Popup' |
iframe title attribute. |
clickSelector |
string |
undefined |
Currently unused hook. |
eventName |
string |
'qbo-popup' |
Name of the custom event re-dispatched from intercepted anchor clicks, and listened for to trigger showElement. |
target |
string |
undefined |
CSS selector for the element to attach the click/eventName listeners to. Falls back to document if unset. |
listen |
boolean |
true |
If true, intercepts click on any anchor under target and converts it into eventName. Set false if you'll dispatch eventName yourself. |
Slots
| Slot | Description |
|---|---|
button |
Optional trigger that toggles the popup directly. |
content |
The modal markup itself (iframe + close button). Fully overridable, but a full default modal/iframe layout is provided out of the box. |
<qbo-popup-listener target="#article-body" event-name="qbo-video-popup"></qbo-popup-listener>
<div id="article-body">
<a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch the demo</a>
</div>
Clicking the anchor is intercepted (
preventDefault), so no navigation occurs — instead the href is loaded into the listener's iframe.
QboPopover
<qbo-popover> is a hover-triggered floating panel positioned against a trigger element using @floating-ui/dom (computePosition with flip()/shift() middleware). Use it for lightweight informational tooltips/popovers anchored precisely to a button or icon, as opposed to qbo-popup's simple show/hide-in-place model.
| Attribute | Type | Default | Description |
|---|---|---|---|
placement |
Placement (floating-ui) |
'bottom' |
Preferred side to render the popover relative to its trigger. |
selector |
string |
'*[data-popover-trigger]' |
CSS selector used to locate the trigger element, searched in order: parent element, shadow root, document, falling back to the parent element itself. |
Events (document-level)
| Event | Description |
|---|---|
qbo-popover-open |
Dispatched on document when a popover opens, detail: { menu: this }. Other open qbo-popover instances listen for this and close themselves, so only one is open at a time. |
<button data-popover-trigger>
<i class="icon-info"></i>
</button>
<qbo-popover placement="top">
Additional context shown on hover.
</qbo-popover>
Opens/closes on the trigger's
mouseover/mouseout— it is not click-triggered. The popover toggles anopenCSS class on itself (:host(.open)) rather than anopenattribute/property.
QboMenu
QboMenu (registered as <qbo-menu>) is a floating dropdown/context menu, also positioned with @floating-ui/dom. Unlike qbo-popover, it supports two trigger modes — type="dropdown" (click-triggered) and type="context" (right-click/contextmenu-triggered) — and looks for its own trigger and content as children rather than an external selector.
| Property | Type | Default | Description |
|---|---|---|---|
placement |
Placement (floating-ui) |
'bottom-start' |
Preferred position for the menu content relative to its trigger. |
type |
string |
'dropdown' |
'dropdown' opens on click; 'context' opens on contextmenu (right-click). |
target |
string | null |
null |
For type="context", a selector for the element to attach the context-menu listener to (via closest, shadow root, or document). Falls back to document if unresolved. |
Trigger/content discovery — QboMenu looks inside its own light-DOM children for:
- Trigger:
*[data-trigger], else the first<button>, else itsparentElement. - Content container:
*[data-content], else<aside>, else<menu>.
Events (document-level)
| Event | Description |
|---|---|
qbo-menu-open |
Dispatched on document when the menu opens, detail: { menu: this }, allowing other open menus to close. |
<qbo-menu placement="bottom-end">
<button data-trigger class="btn btn-secondary">Actions</button>
<menu data-content>
<li><a href="#edit">Edit</a></li>
<li><a href="#delete">Delete</a></li>
</menu>
</qbo-menu>
The menu container is expected to supply its own
.openCSS class styling —QboMenutogglesclassList.toggle("open", ...)but ships no default styles.
QboContextMenu
ContextMenu (registered as <qbo-contextmenu>) is a focused, simpler alternative to qbo-menu's type="context" mode — it exists purely to show a slotted menu at the mouse position on right-click, with viewport-edge clamping so the menu doesn't render off-screen.
| Attribute | Type | Default | Description |
|---|---|---|---|
target |
string |
'' |
Selector for the element that should trigger the context menu on right-click, resolved via closest, shadow root, document, in that order. Falls back to parentElement if unset. |
Slots
| Slot | Description |
|---|---|
| (default) | Menu content, only rendered into the DOM while the menu is open. |
<div id="grid-row" style="position: relative;">
Row content…
<qbo-contextmenu target="#grid-row">
<ul>
<li>Edit</li>
<li>Delete</li>
</ul>
</qbo-contextmenu>
</div>
The menu closes on any outside
clickondocument. Positioning is done by directly settingstyle.left/style.topfrom the triggeringMouseEvent, clamped so the menu stays within the viewport.
Tabs
QboTabs
QboTabs (registered as <qbo-tabs>) renders a simple client-side tab strip and content panel from a tabs array of { title, render, key } entries, where render is a pre-built Lit TemplateResult. It also supports dynamically appending tabs at runtime by listening for a configurable custom event.
| Property | Type | Default | Description |
|---|---|---|---|
tabs |
Tab[] ({ title: string, render: TemplateResult, key: number }[]) |
[] |
The set of tabs to render. |
listener |
string | null |
null |
If set, the name of a document-level custom event this instance listens for to append a new tab. |
Events (listened, not fired)
| Event | detail |
Description |
|---|---|---|
(name of listener) |
{ title, render, focus? } |
Appends { title, render } to tabs. If focus is true or omitted, the new tab is made active. |
const tabs = [
{ title: 'Doc 1', render: html`<qbo-document apiendpoint="api://qbo4/documents/summary/20145"></qbo-document>`, key: 1 },
{ title: 'Doc 2', render: html`<qbo-document apiendpoint="api://qbo4/documents/summary/130134"></qbo-document>`, key: 2 },
];
<qbo-tabs .tabs=${tabs} listener="qbo-add-tab"></qbo-tabs>
document.dispatchEvent(new CustomEvent('qbo-add-tab', {
detail: { title: 'New Doc', render: html`<qbo-document apiendpoint="..."></qbo-document>` }
}));
rendervalues must be pre-builtTemplateResults (e.g. from ahtml\...`tagged template), not functions —QboTabsrenderstab.render` directly rather than calling it.
Alerts & Toasts
QboAlert
<qbo-alert> is a static or dismissible inline alert banner styled after Bootstrap's alert variants. Use it for persistent, in-flow messages (form-level errors, page banners) as opposed to qbo-toast's transient, globally-positioned notifications.
| Attribute | Type | Default | Description |
|---|---|---|---|
type |
'info'|'success'|'warning'|'danger'|'primary'|'secondary'|'light'|'dark' |
'info' |
Bootstrap alert color variant. |
dismissible |
boolean |
false |
Shows a close button that hides the alert when clicked. |
dismissed |
boolean |
false |
Reflects dismissed state to the attribute; set true to hide it programmatically. |
Slots
| Slot | Description |
|---|---|
| (default) | Alert message content. |
Events
| Event | Description |
|---|---|
qbo-alert-dismiss |
Fired when the alert is dismissed (via the close button or by setting dismissed = true through the dismiss() method). |
<qbo-alert type="warning" dismissible @qbo-alert-dismiss=${() => console.log('dismissed')}>
Your session will expire in 5 minutes.
</qbo-alert>
QboToast
<qbo-toast> is a single, page-level toast notification container — place exactly one per page (typically near the root of app.html), and any code anywhere on the page can trigger a toast by dispatching a plain document-level CustomEvent, with no direct reference to the element required.
| Attribute | Type | Default | Description |
|---|---|---|---|
duration |
number |
4000 |
Milliseconds each toast remains visible before auto-dismissing. Set 0 to disable auto-dismiss. |
Events (listened, not fired)
| Event | detail |
Description |
|---|---|---|
qbo-toast |
{ message: string, type?: 'success'|'error'|'info' } |
Shows a new toast. |
form-saved |
{ message: string, type?: 'success'|'error'|'info' } |
Shows a new toast automatically — this is the default event name dispatched by QboToastMixin.toast(), so qbo-toast "just works" with any component composed with that mixin. |
document.dispatchEvent(new CustomEvent('qbo-toast', {
detail: { message: 'Saved successfully', type: 'success' }
}));
Only
success,error, andinfoget distinct styling (.toast-success,.toast-error,.toast-info); othertypevalues render with the base.toaststyling only.
QboToastMixin
QboToastMixin is a small mixin — not a custom element — that adds a protected toast() helper to any LitElement subclass, so components don't need to hand-roll CustomEvent dispatch every time they want to surface a notification. It's designed to be composed alongside a loading/fetch-state mixin (QboLoadingMixin, QboFetchStateMixin) and passed as the toast handler to fetchJsonWithState/sendJsonWithState.
API surface
| Member | Signature | Description |
|---|---|---|
toast (protected) |
(message: string, type: ToastType = 'info', eventName = 'form-saved') => void |
Dispatches a bubbling, composed CustomEvent named eventName with detail: { message, type }. The default event name (form-saved) is exactly what <qbo-toast> listens for, so composing this mixin is the standard way to make a component's saves/errors show up as toasts without any manual wiring. |
import { QboToastMixin } from '@quandis/qbo4.ui';
class MyForm extends QboToastMixin(LitElement) {
async save() {
const result = await sendJsonWithState('/api/save', payload, {
handlers: { toast: this.toast.bind(this) },
successMessage: 'Saved successfully',
});
}
}
Relationship to
qbo-toast:QboToastMixinis the producer side (any component can mix it in to raise notifications) and<qbo-toast>is the consumer/renderer side (one instance per page displays whatever gets dispatched). They communicate purely through theform-saved(or customeventName)CustomEvent— there is no direct import or reference between them.
Code Editor (qbo-code)
The qbo-code component wraps CodeMirror 6 as a web component, giving you a real syntax-highlighted code editor without wiring up CodeMirror yourself.
qbo-codeships as a separate bundle (qbo4.ui-code) because CodeMirror pulls in several language packages. It is not part of the core@quandis/qbo4.uibundle — include it as its own script alongside the core bundle:<script defer src="js/qbo4.ui.js"></script> <script defer src="js/qbo4.ui-code.js"></script>
<qbo-code language="sql">
<template slot="code">
SELECT TOP 10 * FROM Contact
</template>
</qbo-code>
Use a
<template slot="code">(rather than plain child markup) to hold the initial source.qbo-codereads the serialized markup of theslot="code"element (or, if none is provided, the element's owninnerHTML) as the editor's starting document — a<template>keeps the browser from parsing/normalizing the code before CodeMirror sees it.
Attributes / Properties
| Property | Type | Default | Description |
|---|---|---|---|
language |
string |
"html" |
Selects the CodeMirror language extension. See table below. |
value |
string |
"" |
Gets/sets the editor's current document text. Setting it replaces the entire document via a CodeMirror transaction; getting it returns the live editor content once initialized. |
minlines |
number |
5 |
Reserved for a minimum-height layout — not currently wired into the CodeMirror configuration. |
Supported languages
language value |
Extension used |
|---|---|
sql |
@codemirror/lang-sql |
xml |
@codemirror/lang-xml |
javascript |
@codemirror/lang-javascript |
(anything else, including html) |
@codemirror/lang-html |
qbo-codedoes not fire a change event. To read edited content, pull.valueon demand (e.g. from a button handler) rather than listening for a DOM event.
const editor = document.getElementById('htmlSample') as HTMLElement & { value: string };
document.getElementById('htmlRender')!.innerHTML = editor.value;
Markdown Rendering (qbo-markdown)
qbo-markdown wraps zero-md to render Markdown as a web component. It accepts content either from a remote src or from an inline <script type="text/markdown"> child.
<!-- From a URL -->
<qbo-markdown src="https://raw.githubusercontent.com/quandis/fanniemae/main/README.md"></qbo-markdown>
<!-- Inline -->
<qbo-markdown>
<script type="text/markdown">
# Hello World
Markdown [is cool](https://example.com).
</script>
</qbo-markdown>
Attributes / Properties
| Property | Type | Description |
|---|---|---|
src |
string |
URL to fetch Markdown from. Rendering is delegated to the underlying zero-md element. |
Be careful with indentation inside an inline
<script type="text/markdown">block: 4 or more leading spaces are interpreted as a fenced code block by the Markdown spec, so match your script tag's indentation to your intended Markdown structure (or de-indent the Markdown itself).
Registering qbo-markdown also registers the underlying zero-md custom element globally; you generally won't need to use <zero-md> directly.
Document Viewer (qbo-docviewer)
qbo-docviewer renders an external document (PDF, HTML page, etc.) in an iframe. It also mixes in QboFetchMixin, so it can optionally fetch JSON/HTML from an API endpoint as a side effect (e.g., to check availability or fetch document metadata) and report success/failure via events.
<qbo-docviewer src="/documents/contract-123.pdf"></qbo-docviewer>
Attributes / Properties
| Property | Type | Default | Description |
|---|---|---|---|
src |
string |
"" |
The URL rendered inside the iframe. If empty, nothing renders. |
renderInHost |
boolean |
false |
Declared for a future light-DOM rendering mode — not currently wired to createRenderRoot(), so the component always renders into its shadow root today. |
apiEndpoint |
URL | null |
null |
(from QboFetchMixin) When set, triggers a fetch on connect (or whenever it changes). |
method, accept, headers, payload, fetchOnLoad, jsonData, html, error |
— | — | (from QboFetchMixin) — see Fetching Data above for the full contract. |
apiEndpointand theiframe'ssrcare independent. SettingapiEndpointdoes not change what's displayed in theiframe— it only drives the mixin's own fetch, which reports through the events below. Usesrcfor the document that's actually shown.
Events
| Event | Fired when |
|---|---|
qbo-updated |
(from QboFetchMixin) the apiEndpoint fetch succeeds. |
qbo-error |
(from QboFetchMixin) the apiEndpoint fetch fails. detail is the caught error. |
Flowchart Editor (qbo-flowchart)
qbo-flowchart is a JointJS-backed diagram editor: a canvas plus a draggable shape palette, with built-in undo/redo, multi-select, and inline label editing. Reach for it when you need users to build simple flowcharts/process diagrams (tasks, documents, decisions, links) inside your page, with the result persisted as plain JSON.
Like
qbo-code,qbo-flowchartships as its own bundle (qbo4.ui-flowchart) because@joint/coreis a sizeable dependency:<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jointjs/3.7.7/joint.css" /> <script defer src="js/qbo4.ui.js"></script> <script defer src="js/qbo4.ui-flowchart.js"></script>
<qbo-flowchart id="flowchart"></qbo-flowchart>
const flowchart = document.getElementById('flowchart');
// Persist edits as they happen
flowchart.addEventListener('qbo-flowchart-change', e => {
localStorage.setItem('my-diagram', e.detail.value);
});
// Restore a previously saved diagram
flowchart.value = localStorage.getItem('my-diagram');
Palette & Templates
The right-hand palette is built from the templates property — an array of shape definitions. Dragging a template onto the canvas clones it into a real, editable cell; the templates themselves are never included in value.
flowchart.templates = [
{ label: 'Start', bgcolor: '#ff8a65', color: 'white', element: 'Ellipse' },
{ label: 'Task', bgcolor: '#ffb74d', color: 'black' },
{ label: 'Error', bgcolor: '#e57373', color: 'white' },
];
| Field | Type | Description |
|---|---|---|
label |
string |
Text shown on the shape. |
bgcolor |
string? |
Fill color of the shape body. |
color |
string? |
Label text color. |
element |
string? |
One of Rectangle (default), Polygon (a 45°-rotated rectangle), Ellipse, or Circle. |
If templates isn't set, a default 10-shape flowcharting palette is used (Task, Document, Message, Score, Ledger, Workflow, Process, If/Then, Poll, Advanced). Reassigning templates at runtime rebuilds the palette in place.
Palette layout is controlled by templateHeight (default 30), templateWidth (default 80), and templateSpacing (default 20); port markers use portRadius (default 3).
Value, Persistence & Change Events
| Property | Type | Description |
|---|---|---|
value |
string | null |
Serialized JSON array of the diagram's cells (the palette is never included). Assigning a new value loads that diagram and resets undo history. |
Every edit, undo, or redo re-serializes the user's cells and both updates value and fires qbo-flowchart-change with detail: { value }. Because the component distinguishes its own writes from externally assigned ones, setting flowchart.value = json from your own code triggers a full reload (and undo-history reset) rather than being mistaken for an edit echo.
Undo / Redo
| Member | Description |
|---|---|
canUndo (getter) |
true if a prior state exists to restore. |
canRedo (getter) |
true if a later, previously-undone state exists to restore. |
undo() |
Reverts to the state before the last edit. No-op if canUndo is false. |
redo() |
Re-applies a previously undone edit. No-op if canRedo is false. |
History is capped at 100 entries (oldest states are dropped past that). Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, and Ctrl/Cmd+Y are wired to undo()/redo() automatically (ignored while focus is in an input/textarea).
Selection, Deletion & Label Editing
Click a cell to select it; Shift/Ctrl/Cmd-click toggles additional cells into the selection. Clicking blank canvas clears it. Selected cells are outlined in red.
| Member | Description |
|---|---|
deleteSelected() |
Removes the currently selected cells (and any attached links) from the diagram. |
Pressing Delete/Backspace with a non-empty selection calls deleteSelected() automatically.
Double-clicking a cell opens an inline text input over the shape for renaming its label — Enter commits, Escape cancels, and blurring the input also commits.
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Ctrl/Cmd + Z |
Undo |
Ctrl/Cmd + Shift + Z, or Ctrl/Cmd + Y |
Redo |
Delete / Backspace |
Delete selected cells |
Alt + + |
Zoom in (paper scale +0.1) |
Alt + - |
Zoom out (paper scale -0.1) |
Alt + 0 |
Reset zoom |
Rendering Mode & Canvas Options
| Property | Type | Default | Description |
|---|---|---|---|
renderInHost |
boolean |
true |
Renders into the host element (light DOM) instead of a shadow root, so page-level CSS can style ports/cells directly. |
height |
string |
"600px" |
CSS height of the canvas. |
width |
string |
"100%" |
CSS width of the canvas. |
router |
string |
"manhattan" |
JointJS link router name. |
gridSize |
number |
10 |
Paper grid size, in px. |
gridColor |
string |
"lightgrey" |
Paper background/grid color. |
Events
| Event | Fired when |
|---|---|
qbo-flowchart-change |
The diagram's cells/links change (edit, undo, redo, or an external value load). detail: { value }. |
Dynamic Page Styling (qbo-dynamic-style)
qbo-dynamic-style manages a single <style> block injected into document.head, scoped to the element's own lifetime — it's removed automatically when the element disconnects. Reach for it for page/site-wide theming: swapping CSS custom property values, injecting a fetched theme stylesheet, or building a live style editor, all without touching document.head by hand.
This is distinct from
qbo-style-slot(documented below).qbo-dynamic-styleinjects CSS at the document level — it affects the whole page (any selector, any element).qbo-style-slot(a mixin, not a standalone element) instead clones a slotted<style>into one component's own shadow root, so it can style that single component's internals. Don't reach for one when you mean the other.
CSS Sources
CSS can come from three sources, applied in the order they arrive:
| Source | How |
|---|---|
| Inline slot | <style slot="css"> content, re-applied on every slotchange. |
apiendpoint attribute |
CSS text fetched from a URL (text/css/text/plain). |
css property |
A plain string set from JavaScript. |
<!-- Inline slot -->
<qbo-dynamic-style>
<style slot="css">:root { --qbo-border-color: #0d6efd; }</style>
</qbo-dynamic-style>
<!-- Fetched CSS -->
<qbo-dynamic-style apiendpoint="/api/theme/current"></qbo-dynamic-style>
// Programmatic, with a CSP nonce and persistence
const el = document.querySelector('qbo-dynamic-style');
el.styleNonce = document.querySelector('meta[name=csp-nonce]')?.content ?? '';
el.persist = true;
el.persistKey = 'app-theme';
el.css = ':root { --primary: hotpink; }';
Attributes / Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
apiEndpoint |
apiendpoint |
string | null |
null |
URL to fetch CSS text from. |
css |
css |
string | null |
null |
Direct CSS string, set programmatically. |
disabled |
disabled |
boolean |
false |
Empties the injected block without removing the element. |
styleNonce |
style-nonce |
string | null |
null |
CSP nonce forwarded to the injected <style nonce="…">. Prefer setting via the JS property — browsers strip nonce from the DOM after parsing an HTML attribute. |
persist |
persist |
boolean |
false |
Saves CSS to localStorage on every write, and restores it on reconnect when no explicit css/apiEndpoint is set. |
persistKey |
persist-key |
string | null |
null |
localStorage key used by persist. Defaults to the element's internal uid (qbo-ds-N) — set a stable key if the element may be recreated (e.g. inside a framework router), since the uid won't survive a remount. |
layer |
layer |
string | null |
null |
Wraps every injected block in @layer <name> { … } for predictable cascade order without !important. Must be a valid CSS ident (dot-separated sub-layers allowed, e.g. base.reset); invalid names are ignored with a console warning. |
insertFirst |
insert-first |
boolean |
false |
Inserts the <style> at the start of <head> instead of the end, so injected CSS acts as a base other stylesheets can override. Read once at connection time — toggling later has no effect. |
Persistence
When persist is true, every successful CSS write is saved to localStorage under persistKey (or the element's internal uid if unset — a console warning is logged in that case, since the uid won't survive a remount). On reconnect, if no explicit css or apiEndpoint is present, the stored value is restored automatically. Call clearPersisted() to remove the stored value.
Events
| Event | Fired when |
|---|---|
qbo-dynamic-style-applied |
After every successful CSS write. detail: { id, length }. |
qbo-dynamic-style-error |
A fetch (via apiEndpoint) failed. detail is the Error. |
Style Mixins
Two low-level mixins back the styling story for shadow-DOM components in this library. Neither is a custom element you drop into markup directly — they're composed onto a LitElement subclass via applyMixins().
QboStyleSlotMixin
QboStyleSlotMixin is the escape hatch for piercing styles into a single component's shadow root. Shadow DOM blocks external stylesheets by design; this mixin clones a slotted <style> element into the shadow root so page authors can still target that component's internals.
import { applyMixins, QboStyleSlotMixin } from '@quandis/qbo4.ui';
class MyEl extends applyMixins(QboStyleSlotMixin)(LitElement) { /* ... */ }
<my-el>
<style slot="styles">.my-custom-rule { color: red; }</style>
</my-el>
The slot name defaults to "styles"; override the styleSlotName property on your component to use a different name (useful during a migration). For light-DOM components (renderInHost = true), this mixin is a no-op — external CSS already applies directly, so there's nothing to inject.
Again: this is unrelated to
qbo-dynamic-styleabove.QboStyleSlotMixinscopes to one component's shadow root;qbo-dynamic-styletargets the whole document.
QboStyleMixin
QboStyleMixin is a minimal marker mixin that satisfies the internal IQboStylable contract (readonly _stylable = true), sparing component authors the copy-pasted marker field. It's an internal authoring convention rather than consumer-facing behavior: components using it are still expected to document their consumed --qbo-* CSS custom properties in JSDoc and expose ::part() selectors for shadow-DOM targets — the mixin only supplies the marker, not the documentation or the parts themselves.
class MyEl extends applyMixins(QboStyleMixin)(LitElement) { /* ... */ }
Keyboard Handling (QboKeyboardMixin)
qbo-keyboard-mixin.ts exports QboKeyboardMixin, a mixin (not a component) that wires up two overridable, no-op-by-default hooks:
| Hook | Trigger | Listener scope | Typical use |
|---|---|---|---|
onEscape(e) |
Escape key |
window (fires regardless of focus) |
Closing modals, popups, dropdowns |
onEnter(e) |
Enter key |
host element only (shadow DOM listener) | Submit-on-enter shortcuts |
Both listeners are attached in connectedCallback and torn down in disconnectedCallback. QboModal uses this mixin for its own Escape-to-close behavior.
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import { QboKeyboardMixin } from '@quandis/qbo4.ui';
@customElement('my-modal')
export class MyModal extends QboKeyboardMixin(LitElement) {
open = false;
protected onEscape() {
if (this.open) this.open = false;
}
}
Override only the hook(s) you need — the other stays a no-op. Combine with
applyMixinswhen a component needs several mixins at once.
Clipboard & Download Helpers (qbo-clipboard.ts)
Two standalone async functions for getting data in and out of the browser without a full component:
| Function | Signature | Behavior |
|---|---|---|
copyToClipboard |
(text: string, toast?: ToastFn) => Promise<boolean> |
Writes text via navigator.clipboard.writeText. Calls toast('Copied', 'success') / toast('Copy failed', 'error') if a toast function is supplied. Resolves true/false. |
downloadTextFile |
(content: string, filename: string, type = 'text/plain;charset=utf-8') => void |
Creates a Blob, triggers a synthetic <a download> click, then revokes the object URL. Useful as a clipboard fallback, or for giving users a permanent copy of sensitive text (SSH keys, tokens, etc.). |
import { copyToClipboard, downloadTextFile } from '@quandis/qbo4.ui';
// Paired with QboToastMixin's this.toast:
await copyToClipboard(secretKey, this.toast.bind(this));
// Give the user a durable copy as well:
downloadTextFile(`Private Key:\n${kp.privateKey}\n\nPublic Key:\n${kp.publicKey}`, `ssh-keypair-${kp.algorithm}.txt`);
Speech-to-Text Input (qbo-microphone)
qbo-microphone.ts wraps the browser SpeechRecognition API (webkitSpeechRecognition fallback included) behind a clickable element that dictates into a target field.
| Attribute | Type | Default | Description |
|---|---|---|---|
renderInHost |
boolean |
true |
Renders in light DOM. |
continuous |
boolean |
false |
Passed to SpeechRecognition.continuous; also controls whether recognized text is appended vs. replacing the target's value. |
interimResults |
boolean |
false |
Passed to SpeechRecognition.interimResults. |
maxAlternatives |
number |
1 |
Passed to SpeechRecognition.maxAlternatives. |
for |
string |
'input' |
Selector/id used to locate the target field — tries document.getElementById, then this.querySelector, then this.parentElement?.querySelector, in that order. |
class |
string |
'input-group-text bi bi-headset' |
CSS class applied to the host. |
activeClass |
string |
'bg-primary-subtle' |
Class toggled on the host while actively listening. |
Clicking the element toggles listening on/off. Recognized speech is written into the resolved target: appended when continuous, otherwise it replaces the value; HTMLInputElement (including radio, matched by value), HTMLTextAreaElement, and HTMLSelectElement (matched by option value or text) are all supported.
<input id="notes" type="text" />
<qbo-microphone for="notes"></qbo-microphone>
No custom events are fired. Active state is tracked only on a static "currently listening" reference (so only one instance can be "listening" at a time) and surfaced via
console.log— wire up your own UI feedback beyond theactiveClasstoggle if you need it.
Key/Value Search Box (qbo-search)
qbo-search.ts is a collapsible search input that expands into an editable list of key/value pairs (a lightweight query-builder), positioned with @floating-ui/dom.
| Property | Type | Default | Description |
|---|---|---|---|
payload |
Record<string, string> |
{} |
The current key/value pairs, editable in the expanded dropdown. |
Typing key:value and pressing Enter in the collapsed input adds a pair to payload. Clicking the search icon while expanded collapses the control and fires change.
| Event | detail |
Fired when |
|---|---|---|
change |
{ payload: Record<string, string> } |
The search icon is clicked while expanded. |
<qbo-search></qbo-search>
document.querySelector('qbo-search')!.addEventListener('change', (e: CustomEvent) => {
console.log('search payload', e.detail.payload);
});
Ship your own icon sprite at
/ui/images/qbo-icons.svg— the component references#searchfrom it directly.
Resizable Panes (qbo-resize)
qbo-resize.ts is a drag handle placed between two sibling elements to make them mutually resizable (a splitter/gutter pattern).
| Attribute | Type | Default | Description |
|---|---|---|---|
type |
ResizeType ('horizontal' | 'vertical') |
ResizeType.Horizontal |
Resize axis. Adds a horizontal/vertical class to the host for styling the handle. |
On mousedown, the component reads its previousElementSibling and nextElementSibling and resizes them by setting inline width/height (horizontal) or height (vertical) as the mouse moves, with a 50px floor. Double-clicking the handle, or dispatching a qbo-reset event on document, clears the inline sizing back to stylesheet defaults.
<div class="pane-left">...</div>
<qbo-resize type="horizontal"></qbo-resize>
<div class="pane-right">...</div>
// Reset all qbo-resize panes on the page (e.g. a "reset layout" button):
document.dispatchEvent(new CustomEvent('qbo-reset'));
qbo-resizemust sit directly between the two elements it resizes — it relies on DOM adjacency (previousElementSibling/nextElementSibling), not slots or attributes, to find its targets.
Data-Bound Link (qbo-link.ts)
Renders an anchor whose text is pulled from a data record by property name.
| Attribute | Description |
|---|---|
id, name |
name selects which key of data to render as the link text. |
href |
Anchor href. |
<qbo-linkn name="Name" href="/records/1"></qbo-linkn>
<!-- renders: <a href="/records/1">John Doe</a> -->
This component is a stub as it currently stands:
connectedCallbackhardcodesdata = { ID: 1, Name: "John Doe" }rather than fetching real data, and the class is registered under the tagqbo-linkn, notqbo-link(likely a typo). Treat it as scaffolding rather than a ready-to-use component.
API Context Provider (qbo-api-provider)
qbo-context.ts exposes an IApiService down the DOM tree via @lit/context, so descendant components (notably anything using QboFetchMixin) can consume a shared, named API service without each one calling getApiService directly.
| Export | Description |
|---|---|
apiServiceContext |
The @lit/context context key (createContext<IApiService>(Symbol('qbo-api-service'))). |
QboApiProvider (<qbo-api-provider>) |
Resolves getApiService(name) on connect and whenever name changes, and publishes it via ContextProvider. |
| Attribute | Type | Default |
|---|---|---|
name |
string |
'default' |
<qbo-api-provider name="default">
<!-- all qbo-* components inside can receive the service via context -->
</qbo-api-provider>
QboApiProviderrenders in light DOM, so it never obscures descendant markup — it's purely a context boundary.
User Preferences (qbo-user-preferences)
qbo-user-preferences.ts persists a JSON preferences bag to localStorage and offers an editing dialog, plus a set of free functions used across the package for reading preferences and formatting values.
| Attribute/Property | Type | Default | Description |
|---|---|---|---|
preferences |
object |
{} |
Current preference values, loaded from localStorage at construction. |
key |
string |
'qbo4.ui.preferences' |
localStorage key used to load preferences. |
updatePreferences()andresetPreferences()currently hardcode the'qbo4.ui.preferences'key rather than usingthis.key, so save/reset only round-trip correctly with the default key.
<qbo-user-preferences></qbo-user-preferences>
Beyond the component, the module exports free functions used throughout the package:
| Function | Signature | Purpose |
|---|---|---|
setUserPreference |
(preference, value, storageKey = 'qbo4.ui.preferences') => void |
Merge one preference into the stored JSON bag. |
getUserPreference |
(preference, storageKey = 'qbo4.ui.preferences') => any |
Read one preference back out. |
join |
(values: (string | null | undefined)[], separator = ' ') => string |
Filters falsy values, then joins. |
formatDate |
(dateString, format?) => string | null |
Formats a date using a C#-style format string (yyyy, MM, dd, HH, mm, ss, tt, ddd/dddd), mapped to Intl.DateTimeFormat options. Falls back to the dateFormat user preference, then 'MM/dd/yyyy'. |
formatMoney |
(amount, currency = 'USD', locale = 'en-US') => string |
Intl.NumberFormat currency formatting. |
formatPercentage |
(value, decimals = 2, locale = 'en-US') => string |
Intl.NumberFormat percent formatting. |
timeRemaining |
(targetDate, interval = 'months') => TemplateResult | null |
Renders remaining years/months until targetDate as a Lit template fragment. |
import { formatDate, formatMoney, setUserPreference } from '@quandis/qbo4.ui';
setUserPreference('dateFormat', 'dddd, MMM dd yyyy');
formatDate('2026-07-14'); // uses the 'dateFormat' preference just set
formatMoney(1999.5); // "$1,999.50"
Feature Auto-Discovery (qbo-features)
qbo-features.ts is a transparent wrapper placed once around a page (typically the base XSLT template) that scans its subtree for custom elements the browser hasn't defined yet, and asks the host app to lazy-load them.
| Event | detail |
Fired when |
|---|---|---|
qbo-features-needed |
{ elements: string[] } (deduped tag names) |
On connect, after a microtask (so server-rendered children are already present), if any :not(:defined) elements are found in the subtree. |
<qbo-features>
<qbo-fancy-widget></qbo-fancy-widget>
<!-- if qbo-fancy-widget isn't registered yet, qbo-features-needed fires with { elements: ['qbo-fancy-widget'] } -->
</qbo-features>
document.addEventListener('qbo-features-needed', async (e: CustomEvent<{ elements: string[] }>) => {
for (const tag of e.detail.elements) {
if (tag === 'qbo-fancy-widget') await import('./qbo-fancy-widget.js');
}
});
qbo-featuresrenders withdisplay: contentsand a bare<slot>— it adds no visual box, so it's safe to wrap around arbitrary page markup.
Error Message Extraction (qbo-error.ts)
Two pure functions for turning a failed API response into a user-presentable string; used internally by the fetch/toast mixins and available for direct use.
| Function | Signature | Behavior |
|---|---|---|
extractErrorMessage |
(body: any, fallback = 'Request failed') => string |
Checks, in order: body.message, body.title/body.detail (ASP.NET ProblemDetails), body.errors as an object (first key's first string — ASP.NET validation errors), body.errors as an array (string, .description, or .message). Returns fallback if nothing matches. |
parseErrorResponse |
(response: Response, fallback = 'Request failed') => Promise<string> |
Reads response.text(), tries JSON.parse + extractErrorMessage, falls back to the raw text, then to `${fallback} (${response.status})` if the body can't be read at all. |
import { parseErrorResponse } from '@quandis/qbo4.ui';
const response = await fetch('/api/orders', { method: 'POST', body });
if (!response.ok) {
const message = await parseErrorResponse(response, 'Could not place order');
this.toast(message, 'error');
}
Debug Logging Badge (qbo-log-counter)
qbo-logging.ts renders three small badges (error/warning/info counts) that increment whenever the document sees a corresponding custom event — a quick visual tally for a debug console or admin footer.
| Attribute | Default |
|---|---|
errorClass |
'badge text-bg-danger' |
warningClass |
'badge text-bg-warning' |
infoClass |
'badge text-bg-info' |
Listens on document for qbo-error, qbo-warning, and qbo-info, incrementing errorCount/warningCount/infoCount respectively (also logging the event to the console).
<qbo-log-counter></qbo-log-counter>
document.dispatchEvent(new CustomEvent('qbo-error', { detail: 'Something failed' }));
// qbo-log-counter's error badge increments to 1
The per-badge click handler (hide the clicked badge, enlarge the remaining ones) reads as exploratory/unfinished — don't depend on that interaction; the count-tracking behavior is the stable part.
Utility-Only Entry Point (@quandis/qbo4.ui/utils)
If you only need qbo's mixins and pure helper functions — not the custom elements themselves — import from the utils subpath instead of the package root:
import { applyMixins, copyToClipboard, formatDate, QboKeyboardMixin } from '@quandis/qbo4.ui/utils';
utils.ts re-exports composition helpers (qbo-base.js), mixins that never call customElements.define (qbo-fetch, qbo-fetch-state-mixin, qbo-keyboard-mixin, qbo-loading, qbo-paginated-list-mixin, qbo-style, qbo-style-slot, qbo-toast-mixin), and pure utility modules (qbo-clipboard, qbo-error, qbo-fetch-state, qbo-format, qbo-json, qbo-url).
Because none of these modules register custom elements, bundlers can tree-shake this entry point aggressively — useful in contexts (SSR, non-Lit apps, Node tooling) where you want the logic without pulling in the whole component set.