npm.io
4.0.1-CI-20260723-202237 • Published 4 weeks ago

@quandis/qbo4.ui

Licence
MIT
Version
4.0.1-CI-20260723-202237
Deps
12
Size
49.7 MB
Vulns
0
Weekly
0

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


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 (or services.getService), never against a bare tsyringe container import. getApiService, qbo-api, and IValidate lookups all resolve through services.container specifically — 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, applyMixins returns Constructor<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 — skipping super silently 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; error is also set to true.

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, unregistered QboFetchMixin(LitElement) class) is also exported for cases where you want to extends QboFetch directly instead of composing via applyMixins.

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-hub attribute / signalrHub property (a URL string) is what triggers a connection — nothing connects until it's set.
  • connectedCallback() connects if signalrHub is already set at connect time.
  • updated() watches for changes to signalrHub: 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-reconnecting event — the mixin only wires up onclose/onreconnected, not SignalR's onreconnecting. If you need an in-between "reconnecting" UI state, extend the mixin or poll signalrConnected.

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 the apiEndpoint. This is useful for a GET operation.
  • A method="POST" will post the json object as the body of the request.
  • If method is not defined, a GET will be used if json is null. Otherwise, a POST will be used.
  • The json object can be a string, or an object. If it is an object, it will be stringified.
  • Additional headers can be specified in the qbo-api component.

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):

  • getApiService also accepts a URL instance, not just a string — it reads url.href before parsing the api:// scheme.
  • The Accept header controls response parsing, not just content negotiation: RestApiService.fetch only calls response.json() when Accept is exactly application/json; any other value (including the application/xml example above) returns raw response.text() for you to parse yourself.
  • <qbo-api> registers itself via an exported registerRestApi(name, apiEndpoint, headers?, method?) sugar function (from RestApiService.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 template attribute — the id of an external <template> element. On connect, the template's content is cloned into the element's own light DOM, then data (if set) is substituted into the resulting markup via ${expr} placeholders (see substitute() above).
  • Plain markup as direct children — no template/data needed 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.

data accepts 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 changing template).

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 = true is 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(...)) for formAssociated to 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 name into an array when more than one shares a name, otherwise a boolean), radios, number/range (coerced to Number or null), multi-<select> (array of values), and merges in the .value of any nested form-associated custom element (qbo-select, qbo-fieldset, qbo-form-element, qbo-typeahead, or your own QboFormMixin-based element). Native inputs nested inside one of those custom elements are skipped from qbo-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 inputs disabled, so they're excluded from both validation and the submitted payload automatically. Re-checks on change/input on the parent form and again after qbo-form-loaded (so prefilled data doesn't leave a group in the wrong state).
  • Repeating: row markup comes from a single <template> child; .value returns an array of plain objects (one per row) rather than FormData.

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-select or qbo-form-element inside a collapsible qbo-fieldset has its own .value FormData folded into the fieldset's aggregated value, so nothing double-counts when qbo-form reads 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 against historyIcon/helpIcon classes, 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/apiEndpoint over hand-building <option>s — the empty option is always injected first, and defaultValue reconciliation happens automatically once both jsonData and 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-local fetches 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 use filter-local against 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-select and qbo-typeahead, qbo-datalist is not form-associated — it only populates the datalist and (optionally) the paired hidden field. The visible <input> you point list at 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-selectable is a selection-state utility, not a form participant — it does not implement QboFormMixin and has no .value/name for qbo-form to read. If you need the selection to submit with a form, read e.detail.values yourself 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-upload is not form-associated — it does not implement QboFormMixin, so qbo-form's automatic payload collection won't pick it up. Listen for qbo-file-select and handle the upload (or attach the files to your own submit payload) yourself, e.g. inside a qbo-form-submit handler.


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-exists tag has a value of addresses, which corresponds to the qbo-api name 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() computes operator = operationsKeys.find(...) ?? '!=', which always evaluates to the truthy '!=' fallback, so the else if (depend.startsWith('!')) branch intended to handle !This can never execute. In practice !This is 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:

  1. rows set directly (.rows = data, or via the data slot).
  2. apiEndpoint — fetched automatically via QboFetchMixin.
  3. 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);
});

renderInHost is 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/sizeParam if your API doesn't use the recordStart/displaySize convention 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 icon value — 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's loading state 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>

QboModal renders nothing when open is false rather 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, and qbo-contextmenu solve 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 click listener is attached while open is true so clicking outside the component closes it; clicking anything inside content with data-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 an open CSS class on itself (:host(.open)) rather than an open attribute/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 discoveryQboMenu looks inside its own light-DOM children for:

  • Trigger: *[data-trigger], else the first <button>, else its parentElement.
  • 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 .open CSS class styling — QboMenu toggles classList.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 click on document. Positioning is done by directly setting style.left/style.top from the triggering MouseEvent, 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>` }
}));

render values must be pre-built TemplateResults (e.g. from a html\...`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, and info get distinct styling (.toast-success, .toast-error, .toast-info); other type values render with the base .toast styling 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: QboToastMixin is 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 the form-saved (or custom eventName) 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-code ships as a separate bundle (qbo4.ui-code) because CodeMirror pulls in several language packages. It is not part of the core @quandis/qbo4.ui bundle — 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-code reads the serialized markup of the slot="code" element (or, if none is provided, the element's own innerHTML) 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-code does not fire a change event. To read edited content, pull .value on 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.

apiEndpoint and the iframe's src are independent. Setting apiEndpoint does not change what's displayed in the iframe — it only drives the mixin's own fetch, which reports through the events below. Use src for 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-flowchart ships as its own bundle (qbo4.ui-flowchart) because @joint/core is 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-style injects 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-style above. QboStyleSlotMixin scopes to one component's shadow root; qbo-dynamic-style targets 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 applyMixins when 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 the activeClass toggle if you need it.

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 #search from 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-resize must sit directly between the two elements it resizes — it relies on DOM adjacency (previousElementSibling / nextElementSibling), not slots or attributes, to find its targets.

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: connectedCallback hardcodes data = { ID: 1, Name: "John Doe" } rather than fetching real data, and the class is registered under the tag qbo-linkn, not qbo-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>

QboApiProvider renders 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() and resetPreferences() currently hardcode the 'qbo4.ui.preferences' key rather than using this.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-features renders with display: contents and 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.

Keywords