<vn-grid-toolbar> — Implementation

This document is the maintained, authoritative design intent for the toolbar component; keep it in sync with the code.

Architecture

The toolbar is a light-DOM custom element. On connect it:

  1. injects its base + theme stylesheets into <head> (same mechanism as <vn-grid>'s _injectBaseStylesheet / _updateThemeStylesheet);
  2. reads its <template data-vn-grid-toolbar="…"> children;
  3. ensures a .vn-grid-toolbar-content render root;
  4. resolves and links its grid, then subscribes to the grid's events and renders.

Files:

File Responsibility
vanilla-grid-toolbar-events.js Frozen VanillaGridToolbarEvents registry.
vanilla-grid-toolbar.js Element + VanillaGridToolbarItem base + built-in items + default i18n bundle + command allow-list.
vanilla-grid-toolbar.css Base structural styles; consumes --vn-grid-toolbar-* tokens.
themes/vn-grid-toolbar-<theme>.css Per-theme token values (8 themes).
vanilla-grid-toolbar.d.ts Hand-authored types.

Loading & bundling

A host loads a single <script src="vanilla-grid-toolbar.js"> tag (mirrors <vn-grid>'s vanilla-grid.js). vanilla-grid-toolbar.js self-locates via document.currentScript, then auto-loads vanilla-grid-toolbar-events.js from its own directory via sequential <script> injection, exposing window.VanillaGridToolbarReady. This is skippable via window.VanillaGridToolbarSkipAutoload = true or a data-skip-autoload attribute on the toolbar's own <script> tag — vanilla-grid-toolbar.bundle.js (the build.js production artifact, a real concatenation of vanilla-grid-toolbar-events.js + vanilla-grid-toolbar.js) sets the flag internally since nothing is left to fetch. window.VanillaGridToolbarReady always resolves (immediately, when auto-loading didn't run) so a host never has to branch on why. Awaiting it is optional: <vn-grid-toolbar> and every built-in item are defined synchronously regardless, because vanilla-grid-toolbar.js has never had a hard synchronous dependency on window.VanillaGridToolbarEvents — it falls back to an inline literal with the same shape when the real, frozen registry isn't installed yet. See the build.js bundle mechanism (TOOLBAR_BUNDLE_PARTS / buildVanillaGridToolbarBundle) for the build-side half of this.

Item tags (vn-grid-toolbar-status, -search, -command, …) are registered via customElements.define() before vn-grid-toolbar itself. <vn-grid> is defined asynchronously, so when it isn't upgraded yet at the time <vn-grid-toolbar> connects, linking falls through to the "grid not upgraded yet" path, which renders the toolbar's first template synchronously — inside <vn-grid-toolbar>'s own customElements.define() upgrade reaction, i.e. still inside this script's synchronous execution. If the item tags were defined afterward, that first render's binding collection (see Sub-component contract below) would run while they were still plain, un-upgraded HTMLElements, silently caching an empty binding list — and since a template only re-clones on a state transition (or a forced _render(true)), a toolbar whose host authors only one template state would never recover.

Linking (R1)

Resolution order in connectedCallback and on grid attribute change:

  1. grid attribute = the id of a <vn-grid> (document.getElementById);
  2. .grid property set programmatically (element or id string);
  3. fallback: nearest following / first <vn-grid> in the document (warns).

Both the grid="<id>" lookup and setGrid()/.grid = validate the resolved element's tag name (_vnToolbarIsGridTagtagName === 'VN-GRID', reliable even before the definition upgrades the element). An id that resolves to a non-<vn-grid> element is rejected with a console warning and treated as unresolved, falling through to the next step in the resolution order instead of silently linking to (and dispatching grid listeners against) the wrong element.

Because <vn-grid> upgrades asynchronously (auto-loaded definition), the toolbar waits for customElements.whenDefined('vn-grid') when the element exists but is not yet upgraded, then await gridElement.ready() before reading state. When no grid resolves it renders the unlinked template (or nothing) and never throws.

State assembly (R3, §6/§21)

ToolbarState is recomputed on every relevant grid event (vn-grid-selection-changed, vn-grid-loaded, vn-grid-loading, vn-grid-error, vn-grid-busy-changed, vn-grid-column-filter-changed, vn-grid-sort-changed, vn-grid-group-changed, vn-grid-total-row-count-changed, vn-grid-load-more-succeeded). vn-grid-busy-changed is the one that closes the busy window, and without it the window never closed at all: vn-grid-loading/vn-grid-loaded bracket a data FETCH, but setRows() raises the deferred reorder's shimmer before the element emits LOADED, so the recompute meant to end the window read isBusy() as true and cached it — and the shimmer's release fires no lifecycle event. Every load above sortShimmerThreshold with a sort or an applied grouping therefore left the toolbar greyed out for good: busy-gated items dead, and a declared busy template stuck on screen. It fires only on real transitions of isBusy(), so it costs at most two extra recomputes per window, each of which ends in _vnToolbarStatesEqual() when nothing moved. It also makes the opening honest for the first time: a large header sort defers behind the shimmer, and its own vn-grid-sort-changed fires before the pipeline, so until now the toolbar never reported that window at all. vn-grid-group-changed is there because a group change moves groupState / hasGrouping and — when its reorder ran behind the shimmer rather than in-thread — clears busy on its way out with no other event to announce it; without the subscription the toolbar stayed greyed out after a large regroup that no reload followed, which is exactly what a layout reset is once <vn-grid>.clearPersistedSettings() stops reloading (see Local Storage Settings §6.5). The last one exists specifically because infinite-scroll "load more" appends (appendRows()) dispatch no event of their own and vn-grid-loaded only covers the initial load/reload path — without it, getLoadedRowCount()-driven UI (the <vn-grid-toolbar-status> row-count summary) would appear frozen at the initial page while the grid kept loading more in the background. Fields come from the grid element's public getters (getSelectedKeys/Rows, getColumnFilters, getSearchTerm) and the underlying instance (displayRows, getLoadedRowCount, getTotalRowCount, hasMoreRows, isBusy, getSortState). isLoading is api.isBusy(). A separate loadStarted field is true only on the recompute triggered by the grid's LOADING lifecycle event (recompute reason 'loading'): the grid emits LOADING at load start, before its own busy flag flips (and setLoading(true) fires no recompute of its own), so isBusy() can still read false on that very recompute. loadStarted is a status-text-only signal — it is deliberately not folded into isLoading, because isLoading also drives busy-template selection (_resolveStateName). <vn-grid-toolbar-status>'s busy-window guard keys off isLoading || loadStarted, so the reset→requery window of a filter/sort/reload never reads as a torn-down-state Loaded 0 — it renders blank by default, or Loading… with show-transient — without switching the whole toolbar to its busy template. The following LOADED/ERROR event (reason 'loaded'/'error', loadStarted false) normally clears it — but it cannot be relied on to, because LOADED can arrive while a deferred reorder still holds isBusy() true (above). The guaranteed close is the vn-grid-busy-changed recompute (reason 'busy') that fires when isBusy() actually goes false. A third field, busy, is simply isLoading || loadStarted — the whole busy window, bracketed with just the two recomputes that are actually guaranteed to fire (loading at open, loaded/error at close). Neither isLoading nor loadStarted alone has this property: isLoading misses the opening tick (as above), and loadStarted alone would drop back to false on any other recompute that happens to land mid-flight (a filter/sort/selection/total/ loaded-more event — any of the eight _recompute() listens for) — reading as "not busy" before the load has actually settled. busy is the recommended predicate for item-level disabled-when/show-when/hide-when (e.g. <vn-grid-toolbar-export>'s own busy-gating, below) and for hand-written toolbarContext setters (e.g. grid-minimal-js's grid-minimal-theme-selector and grid-minimal-rows-selector, which use it to disable their <select> in lockstep) that want to react to the whole window without re-deriving the OR themselves. busy does not change _resolveStateName()'s busy-template selection (below), which deliberately keeps using raw isLoading only — the one-tick delay before a whole-template swap avoids a flash on the ambiguous opening tick, and is not something this changes. Derived predicates (hasSelection, hasSingleSelection, hasMultiSelection, isEmpty, hasFilterOrSort — true when a column filter or sort is active, the predicate the clearColumnFiltersAndSorting command gates on — and hasActiveRefinements — the broader flag, also true for a non-empty search term) drive template/item logic. isError is true only on the 'error' recompute; errorMessage carries the grid's error text on that same recompute — _onGridError captures vn-grid-error's detail.error.message into a pending slot that _buildState() reads when isError, and any non-error recompute clears it so a stale message can't leak forward.

The unlinked/pre-link zero state (_zeroState()) carries the same shape as the built state — including busy: false and loadStarted: false — so an unlinked toolbar's state.busy/state.loadStarted read false (not undefined) and a hide-when="busy" predicate on an unlinked template evaluates correctly.

Each recompute is field-by-field compared against the previously-applied state (selectedKeys/selectedRows by array-shallow-equality, filterModel by a JSON.stringify structural compare since getColumnFilters()/getSelectedKeys() return fresh instances on every call, everything else by ===) and the render + vn-grid-toolbar-state-changed dispatch are skipped entirely when nothing changed. This matters most for redundant events fired in bursts — e.g. overlapping infinite-scroll "loading" callbacks where isLoading is already true — which would otherwise force a full re-bind on every event. The public refresh() method is exempt from this skip (it is documented as a forced re-render and must not silently no-op).

Template engine (R2, §7)

The active state name is unlinkedselected (when hasSelection) → busy (when isLoading and a busy template exists) → empty. On a transition the content root is cleared and the matching template is cloned fresh; within a state only the context is re-pushed so focus and sub-component internals survive.

The <template data-vn-grid-toolbar="…"> children are scanned on connect and re-scanned on every relink (grid attribute set/changed, setGrid()) and on every public refresh() — there is no one-shot caching. This supports hosts that append the templates after the element is already connected (frameworks like Angular attach the element to the document before wrapper code can add children); such hosts append the templates first, then set the grid attribute. On every (re)clone the toolbar walks the fresh subtree once and caches the toolbarContext-accepting elements (skipping those whose constructor sets a truthy static vnToolbarSkipContext, e.g. the inert <vn-grid-toolbar-spacer>); subsequent recomputes within the same state iterate that cached list instead of re-running querySelectorAll('*') against the whole subtree.

Layout groups (<vn-grid-toolbar-group align="start|center|end">)

Pure CSS, in flow — no measurement, no ResizeObserver. The mechanism the old <vn-grid-toolbar-spacer grow> pair could not provide: two equal spacers split the row's free space equally, which centres whatever sits between them in the gap between the two side blocks, not in the row — off-centre at rest whenever the side blocks differ in width, and visibly sliding whenever any sibling (e.g. an expandable search) grows, by half of what it gained. A start/end group pair fixes this because both are flex: 1 1 0 — always equal to each other, regardless of their own content — so a center group's midpoint is the row's midpoint by construction, and a sibling growing inside the end group takes room from that group's own share rather than from a shared spacer.

vn-grid-toolbar-group[align="start"],
vn-grid-toolbar-group[align="end"] {
    flex: 1 1 0;
    min-width: min-content;
}
vn-grid-toolbar-group[align="center"] {
    flex: 0 1 auto;
    min-width: 0;
}

Two degradation paths, both deliberate:

No max-width and no additional attribute close the oversized-centre case; the shrinkable centre already does, at the cost of a squeezed/clipped centre in a configuration that was already three lines deep.

A bare <vn-grid-toolbar-group> (no align) is flex: 0 0 auto with no zone role — a plain cluster, for grouping related items under shared spacing/wrap behavior without claiming a share of the row.

Like <vn-grid-toolbar-spacer>, the group class sets vnToolbarSkipContext so it is excluded from _collectContextEls's cache — it inherits the toolbarContext setter but never reads it. This does not affect its children: _collectContextEls walks the whole subtree via querySelectorAll('*') (see Template engine above), which finds toolbarContext-accepting elements at any depth, so an item inside a group receives context and reacts to disabled-when/show-when/hide-when exactly as it would ungrouped.

Commands (R5, §8b)

Commands map to already-public <vn-grid> methods via an allow-list; toolbar.registerCommand(name, fn) adds host commands.

All command paths (items' send(), <vn-grid-toolbar-command>) route through toolbar.dispatchCommand(name, arg, source), which fires a cancelable vn-grid-toolbar-command event (bubbling; detail carries grid/state/selection) and then runs the standard default action unless the name is unbound or a listener called preventDefault(). This unifies standard commands (run directly) and custom commands (host logic via the event), and works across frameworks. The richer <vn-grid-toolbar-command> element (localized label, theme-owned CSS-mask icons via --vn-grid-toolbar-icon-<name> + registerIcon, primary/secondary/ghost/danger variants, disabled-when/show-when/hide-when); <vn-grid-toolbar-button> is a ghost-variant alias of it. <vn-grid-toolbar-clear-selection> shares the same .vn-grid-toolbar-command/--<variant> button classes (default secondary) rather than a separate button style, so it picks up every theme's radius/icon-order rules and any per-theme variant override (e.g. the Carbon batch-action bar's inverted primary fill below) without bespoke CSS.

<vn-grid-toolbar-export> also accepts disabled-when/show-when/hide-when, but is not a VanillaGridToolbarCommand subclass (it extends VanillaGridToolbarItem directly) and its disabled-when does not fully override the built-in defaults the way Command's does. Export already disables itself whenever there's nothing valid to export (scope="selected" with no selection, or unlinked otherwise) or whenever state.busy is true (a filter/sort/search/reload in flight, or a load-more prefetch — exporting is comparatively heavy, potentially Worker-offloaded, and shouldn't start against rows that are mid reset→requery) — these are domain invariants, not readiness gates, so disabled-when here can only add an extra disabling condition on top; it can never re-enable an export that's structurally invalid or busy-gated. show-when/hide-when have no existing default to protect and behave exactly like Command's. Note: busy-gating also disables Export during a plain infinite-scroll load-more prefetch, even though the rows already loaded stay valid throughout one — accepted as the simpler, safer default, with no attribute-based opt-out.

A click sends { scope } to the exportToExcel command and handles the promise the command returns: exportToExcel() rejects with a coded error, and Export ignores EXPORT_CANCELLED (the user pressed Cancel in the grid's overlay) and EXPORT_IN_PROGRESS (an export was already running) and logs any other code through the toolbar logger, so no rejection goes unhandled. The grid shows the failure to the user itself. A host that replaces the command with registerCommand('exportToExcel', …) should return the export's promise for the same reason.

Sub-component contract (R3/R5, §8a)

After each (re)bind the toolbar assigns el.toolbarContext = { state, grid, gridElement, toolbar } to every cached toolbarContext-accepting element (collected once per clone — see Template engine above). The optional VanillaGridToolbarItem base stores it, calls contextChanged(), and provides state / grid / gridElement / selectedKeys / send(cmd, arg) / t(key, params) helpers. Context is dropped on disconnect and on template swap. Disconnect teardown is deferred by one microtask and skipped when the toolbar is immediately re-connected (reparent tolerance, mirroring <vn-grid> — see docs/vanilla-grid/01-web-component-implementation.md §4.2), so moving the toolbar in the DOM does not drop its grid listeners or context.

Built-in search item — ✕ clear button (both variants)

<vn-grid-toolbar-search> always builds an inline ✕ clear button (aria-label from the searchClearLabel i18n key) next to the input; the vn-grid-toolbar-search--active host class (toggled whenever the input is non-empty, in both variants) drives its visibility and the accent border. In the standard (non-expandable) variant the host is a position: relative inline-flex wrapper and the ✕ is absolutely positioned inside the input's right edge (the input reserves padding-right for it while active); ✕ click and Escape-with-text clear the input, dispatch search('') immediately (cancelling any pending debounce), and keep focus in the input. The native WebKit ::-webkit-search-cancel-button is suppressed for both variants — the item renders its own ✕.

Built-in search item — expandable variant (§18)

<vn-grid-toolbar-search expandable> is a presentation switch on the existing item, not a new element. expandable is a boolean observed attribute; without it nothing changes. The item keeps a single internal _expanded flag and derives the visual state as expanded = hasText || _expanded — so a non-empty term always renders expanded (never hidden), which also makes the external-term sync and the template-swap re-clone come back up in the right state for free.

Built-in search item — search-fields tooltip

<vn-grid-toolbar-search> reveals a hover hint listing the fields free-text search is restricted to, whenever gridElement.getSearchFields() returns a non-empty array (see DataManager Implementation §11.1). Fully automatic — no attribute, no host wiring — and driven entirely by whichever DataManager is attached:

Theming (R4, §10/§22)

The grid's --vn-grid-* tokens are scoped to .vn-grid-table-container and do not cascade to a sibling, so the toolbar ships its own themes/vn-grid-toolbar-<theme>.css for all 8 themes. Each sets --vn-grid-toolbar-* to match the grid palette (accent / surface / border / text / hover / selected). Each theme also sets --vn-grid-toolbar-font-family / --vn-grid-toolbar-font-size to mirror that theme's grid body-row font (.vn-grid-body-table td). These are applied on the vn-grid-toolbar host and inherited by every text surface — status, status message, command buttons, search input, and tooltip — so all toolbar text reads as part of the same surface as the grid rows. The message uses the theme's main text color (--vn-grid-toolbar-color) and renders as plain grid text for info/success; only error tints the text, and no severity adds a background. Carbon / Carbon-Dark set --vn-grid-toolbar-radius: 0 and remove the outer border on .vn-grid-toolbar-content so the toolbar sits flush above the grid, matching the Carbon specification. In selection mode (data-vn-grid-toolbar="selected") Carbon / Carbon-Dark render a flat interactive-blue (#0f62fe) batch-action bar with white foreground — instead of the default translucent --vn-grid-toolbar-selected-bg tint — by re-mapping the --vn-grid-toolbar-* tokens on the selected content element so the base button/text rules read correctly against the blue surface. When theme is unset the toolbar mirrors gridElement.theme and follows it live via the grid's vn-grid-attribute-changed event. The initial theme is also re-resolved once _linkGrid()'s async grid-ready wait settles (not just on connectedCallback, when _gridEl isn't known yet) — otherwise a host that sets the grid's theme once at mount and never changes it again (e.g. restoring a persisted theme) would leave the toolbar stuck on default.

Custom-theme registry + resolution order

The toolbar carries a custom-theme registry mirroring the grid's (VanillaGridElement.registerTheme / getSupportedThemes /_themeRegistry):

A custom theme is a paired, per-component registration — the grid theme file declares --vn-grid-* tokens scoped to .vn-grid-table-container while the toolbar file declares --vn-grid-toolbar-* on its own host, so the two are genuinely different files and the grid's path can't be reused. Because the components load as independent scripts (neither imports the other), a host registers the theme on both:

VanillaGridElement.registerTheme('corporate',        '/themes/grid-corporate.css');
VanillaGridToolbarElement.registerTheme('corporate', '/themes/toolbar-corporate.css');

_updateThemeStylesheet() resolves the <link> href in priority order:

  1. the toolbar's own theme-css-path attribute (explicit override) — unchanged;
  2. otherwise the resolved theme name (own theme attribute, else the mirrored grid theme name): a. the toolbar _themeRegistry[name] (custom registry) → that path; b. else built-in SUPPORTED_THEMESthemes/vn-grid-toolbar-<name>.css; c. else fall back to default.

dataset.theme reflects the effective applied name (so a mirrored-but- unregistered custom name shows as default, matching the stylesheet actually linked). Because mirroring already returns the grid's name, a corporate registered on both sides resolves correctly with no extra mirroring plumbing.

Mismatch info-log (deduped)

Divergence is a supported configuration (a toolbar and grid may legitimately run different themes), so a mismatch is surfaced as a single console.info via the toolbar logger (_toolbarLogger.info) — never a warning, and with no behavior change. _maybeLogThemeMismatch() fires when the toolbar's effective theme name differs from gridElement.theme (covering both an explicit divergent theme attribute and a mirrored custom grid theme that has no toolbar stylesheet and so falls back to default). It is deduped per (grid-name, toolbar-name) pair: _updateThemeStylesheet() re-runs on connect, on the toolbar's own attribute changes, on every grid vn-grid-attribute-changed, and after the async grid-ready settles, so the instance stores this._lastThemeMismatchKey = gridName + '|' + toolbarName and logs only when the freshly-resolved key differs. Keying on both names means a grid switch from one mismatching theme to another re-announces. The key is cleared on disconnect (beside the theme-<link> refcount teardown) so a reconnect re-announces an ongoing mismatch once.

Built-in theme list is kept in sync by convention

The toolbar's and grid's SUPPORTED_THEMES arrays are hand-kept copies. Under the zero-dependency / no-bundler constraint there is no shared module to hold a single list, and unifying would couple the two independently-loaded scripts. The coupling is a documented convention instead: adding a built-in theme means adding it (and its stylesheet) to both componentsvanilla-grid's SUPPORTED_THEMES + src/vanilla-grid/themes/vn-grid-<name>.css and vanilla-grid-toolbar's SUPPORTED_THEMES + src/vanilla-grid-toolbar/themes/vn-grid-toolbar-<name>.css.

i18n (§20)

The toolbar owns its localization: a default en bundle plus messages / setMessages(map) / locale. Keys use {param} interpolation. Items read labels/placeholders through this.t(...).

Status: autonomous summary vs. host-driven message

The toolbar ships two status elements with a deliberate division of labor — neither is a superset of the other:

A host that wants the row-count line and transient messages mounts both elements in the same template; each owns a distinct message class, so there is no last-writer-wins race between them.

Number formatting

toolbar.formatNumber(value) resolves, in order: a host override registered via toolbar.setNumberFormatter(fn), else a lazily-built Intl.NumberFormat(this.locale) cached per locale (recomputed automatically when the locale attribute changes, since the cache key is the locale string itself). setNumberFormatter(null) clears the override and restores the Intl default. Any VanillaGridToolbarItem subclass can call this.formatNumber(v), which delegates to the linked toolbar.

Additive grid-API surface (§19)

The toolbar consumes three backward-compatible additions on vanilla-grid, degrading gracefully when absent: