<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:
- injects its base + theme stylesheets into
<head>(same mechanism as<vn-grid>'s_injectBaseStylesheet/_updateThemeStylesheet); - reads its
<template data-vn-grid-toolbar="…">children; - ensures a
.vn-grid-toolbar-contentrender root; - 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:
gridattribute = theidof a<vn-grid>(document.getElementById);.gridproperty set programmatically (element or id string);- 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 (_vnToolbarIsGridTag — tagName === '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 unlinked → selected (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:
- Side
min-width: min-contentfloor. A side that genuinely cannot fit its equal share of the row grows past it, pushing the centre off true 0 rather than overflowing the row. Below that floor "centred" stops holding exactly, but the row still never overflows —.vn-grid-toolbar-content's ownflex-wrap: wrapis the escape valve once even that fails, at which point the zones land on different lines and "centred" has no meaning. - Center
min-width: 0(i.e. NOTflex-shrink: 0, which is the trap). Without it an oversized centre group floors at its ownmin-contentand cannot give way, which is the one configuration measured to push a document scrollbar (three wrapped lines of content crammed into the centre group).flex: 0 1 autois the flex default — the rule in practice is "do not writeflex-shrink: 0on the centre group."
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.
- State is reflected as host classes only
(
vn-grid-toolbar-search--expandable/--collapsed/--expanded/--active); all styling lives invanilla-grid-toolbar.css. Expansion consumes the row's free space via a transitionedflex-grow(dominant factor999so it wins over<vn-grid-toolbar-spacer grow>); it never overlays siblings. Duration token:--vn-grid-toolbar-search-expand-duration(default0.15s), zeroed underprefers-reduced-motion: reduce. - Collapse-on-blur listens for
focusouton the host with ane.relatedTargetcontainment check (the input's rawblurwould race the ✕ button's click). Collapse only happens when the input is empty. - ✕ click and
Escape-with-text clear the input and dispatch thesearchcommand with''immediately (cancelling any pending debounce timer); ✕ then collapses and returns focus to the lens button,Escapestays expanded-empty, and a secondEscape(empty input) collapses. ThekeydownhandlerpreventDefault()s so the nativetype=searchEscape reset doesn't double-fire a debounced empty search. - The lens button carries
aria-label(i18n keysearchExpandLabel) andaria-expanded; the ✕ carriesaria-label(searchClearLabel). max-widthattribute — caps how far the expanded box'sflex-grow: 999can consume the row's free space (plain number, px; defaultVanillaGridToolbarSearch.DEFAULT_MAX_WIDTH = 480, unlike<vn-resize-box>'s own unconstrained-by-defaultmax-widthconvention)._maxWidth()parses the attribute the same way_delay()parsesdelay(falls back to the default when not a finite positive number);_applyMaxWidth()sets it as an inlinethis.style.maxWidth, applied unconditionally inconnectedCallback()/attributeChangedCallback()— a no-op for the standard presentation (noflex-growthere) but harmless, and it also caps a host that grows the standard variant via its own custom CSS. No CSS selector changes needed: the inline style combines with.vn-grid-toolbar-search--expanded'sflex-growexactly like<vn-resize-box>'s own inline style combines with its layout — flexbox stops growing once the cap is hit.
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:
Computed in
contextChanged()(already called on connect, attribute changes, and every grid-state notification), so it stays in sync withsetSearchFields()calls the same way the input's value stays in sync withsetSearchTerm().Reuses
<vn-grid-toolbar-info>'s tooltip markup: arole="tooltip"span (no JS show/hide logic — pure CSS reveal),textContent-only (neverinnerHTML, so a field name can never inject markup). Anchored on the item's own host element (vn-grid-toolbar-search { position: relative }) rather than a dedicated icon, so it covers the standard andexpandablepresentations uniformly with no new visible chrome.Hover-only, hidden while a term is being edited — the standard (non-
expandable) presentation reveals the hint via two CSS rules: (1):hover:not(:focus-within)— hover while unfocused — and (2):hovercombined with.vn-grid-toolbar-search-input:placeholder-shown— hover while the input is empty, focused or not (both also carry the:not(.vn-grid-toolbar-search--expanded)guard, see below). Together these mean the hint shows whenever there's no term actively being typed — including right after clearing the box (backspace, ✕, orEscape) without needing to blur first, and immediately on hovering an already-empty focused box. It only hides while a non-empty term is present and the item has focus. Theexpandablepresentation keeps the older, stricter behavior — hidden as soon as the item is expanded (lens click auto-expands- focuses an empty input;
:not(.vn-grid-toolbar-search--expanded)guards both rules above out of that variant), matching its collapsed-at-rest model; it isn't used by any sample app today.
- focuses an empty input;
Edge-aware alignment — the tooltip is left-anchored by default, but the item often sits at the toolbar's right end (the collapsed lens especially), where a left-anchored box overhangs. That overhang causes two distinct problems, and the second is why alignment is computed eagerly rather than at reveal time:
- Clipping, when revealed. The box extends past the toolbar into the host
app's
overflow: hiddenpanels and is cut off — a z-index cannot fix ancestor clipping, only anchoring the other way can. - Document overflow, while hidden. The box is hidden with
visibility: hidden, which suppresses painting but not layout. A hidden tooltip still contributes to the document's scrollable overflow area, so an overhang widens the page and raises a horizontal document scrollbar on a fresh load, before anyone hovers anything. The hidden box must therefore not overhang at rest — placement is a property of the layout, not of the reveal.
_alignSearchFieldsTooltip()(a thin wrapper over the shared_vnToolbarAlignTooltip()) sets exactly one of.vn-grid-toolbar-search-tooltip--align-left/--align-righton every pass, and runs at three points: when the hint's text changes (_applySearchFieldsTooltip()), when the presentation changes (_applyExpandable()— the input ⇄ collapsed-lens flip moves the item a long way along the row, and is exactly the Carbon theme switch that triggered the bug), and on the host'smouseenter. It decides geometrically — would a left-anchored box, spanning[anchorLeft, anchorLeft + width], cross the toolbar's (or, for a toolbar-less detached item, the viewport's) right edge? — rather than by un-anchoring and re-reading the box, because the collapsedexpandablelens is right-anchored by CSS default and its current rect is not the left-anchored one. This is alignment-only JS; visibility remains pure CSS.Those three passes are not sufficient on their own, because they can measure a layout that is not yet final. A custom element upgrades when its definition runs, which is not necessarily after the component's own stylesheet has applied. Firefox upgrades first: the pass reads an unstyled item (full width, at
x: 0) and an uncapped tooltip, correctly concludes "fits left" for that layout, and is never re-measured once the sheet lands and the item collapses to a 30px lens at the row's right end. Chromium applies the sheet before the upgrade and never shows it — so this reproduced only in Firefox, only on a load where the theme was already persisted (the dropdown path settles the stylesheet first), and it passed the entire Chromium-only test suite._vnToolbarWatchTooltipGeometry()closes this with a per-itemResizeObserverover the item, the tooltip and the toolbar — re-aligning whenever the geometry the decision depends on actually changes. That is the correct signal: it catches the late stylesheet, and subsumes container and window resizes, which previously relied onmouseenterto correct after the fact. The callback only toggles classes affectingleft/right, never size, so it cannot feed back into itself. The observer is established inconnectedCallback()(outside the one-time build block, so a reparented item re-observes) and dropped indisconnectedCallback(). On an engine withoutResizeObserverit is skipped and themouseenterpass remains the backstop.Because the engines diverge on upgrade-vs-stylesheet ordering rather than on layout maths, the placement specs run under a
firefox-placementPlaywright project as well aschromium(seeplaywright.config.js). Add a spec there when it asserts geometry that a stylesheet's arrival changes.The stylesheet backs the JS with a default that cannot overhang before any of it runs: the collapsed
expandablepresentation is right-anchored by default, since its 30px lens is normally pinned at the row's right end and a box hanging past the left document edge raises no scrollbar in LTR. That default is written as…--expandable:not(…--expanded) .vn-grid-toolbar-search-tooltip:not(…--align-left)— the:not(--align-left)is load-bearing. Without it the default would out-specify the baseleft: 0and pin a collapsed lens's tooltip right-anchored forever, which is wrong for a lens at the row's start. The rule and the--align-leftclass are a pair; changing one without the other reintroduces that.- Clipping, when revealed. The box extends past the toolbar into the host
app's
Hidden (
hiddenattribute, not just opacity) whenever search is unrestricted —nullor[]— which is the default for every sample app that doesn't configure field-scoped search, so most integrations see zero visual change.Each field is shown as its column's header label, resolved via
grid.getHeaderMainText(column)— the same function the grid itself renders headers with, so a host's customformatting.getHeaderMainTextoverride (e.g. an i18n-driven header resolver, as inpeople-cities-js) is honored rather than re-derived._findColumnForField()matches the searched field againstgrid.columnsbykey/fieldexactly first, else — for a dotted path likeODataDataManager'sHometown.Name— by the path's first segment (e.g. against a column keyedHometown); a field with no matching column falls back to the raw field string verbatim. i18n keysearchFieldsTooltip(default'Searching: {fields}'); the resolved label list is joined viaIntl.ListFormat(toolbar.locale, { type: 'conjunction' })when available, else a plain comma join.For
ODataDataManager, this only lights up whilesearchMode: 'filter'— in the defaultsearchMode: 'search'the configuredsearchFieldsis inert (the server owns$search's scope) andgetSearchFields()correctly reportsnull, so the tooltip stays hidden even if the manager was constructed with a field list.
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):
VanillaGridToolbarElement.registerTheme(name, cssPath)— register the toolbar-side stylesheet for a custom themename(validated exactly like the grid's: both arguments must be non-empty strings).VanillaGridToolbarElement.getSupportedThemes()— built-inSUPPORTED_THEMESplus every registered name.
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:
- the toolbar's own
theme-css-pathattribute (explicit override) — unchanged; - otherwise the resolved theme name (own
themeattribute, else the mirrored grid theme name): a. the toolbar_themeRegistry[name](custom registry) → that path; b. else built-inSUPPORTED_THEMES→themes/vn-grid-toolbar-<name>.css; c. else fall back todefault.
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 components — vanilla-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:
<vn-grid-toolbar-status>is autonomous. It composes the standing data-volume summary — "where am I in the dataset" — entirely from grid state (getLoadedRowCount()/getTotalRowCount()/hasMoreRows()/ selection) via_recompute()/contextChanged(); the host never pushes text into it. Numbers (loaded/total/selected) are formatted throughtoolbar.formatNumber()beforet()interpolation, sostatusLoadedTotalrenders"Loaded 1,000/500,000"out of the box. By default it renders only that summary — and renders nothing while the grid is busy with nothing loaded yet ((isLoading || loadStarted) && loadedCount === 0, i.e. the reset→requery window of a filter/search/reload or the very first load): the counts in that window are torn-down state, soLoaded 0must never render as if it were a fresh measurement. A genuine empty result (load settled, nothing matched) still showsLoaded 0. The genericstatusLoading/statusErrortexts are likewise suppressed by default so a host mounting both elements never gets duplicate loading/error text. The booleanshow-transientattribute rendersLoading…/Errorin those windows instead of blank (not recommended when a<vn-grid-toolbar-status-message>is also mounted).<vn-grid-toolbar-status-message>stays host-driven — it owns transient, app-specific messages the toolbar cannot know on its own (env switches, metadata fetch progress, error text with interpolated detail), pushed viasetStatusMessage(text, severity).
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:
VanillaGrid.isBusy()+gridElement.isBusy()/gridElement.isLoading— busy flag spanning initial load and infinite-scroll "load more".gridElement.getSearchTerm()— reflects the DataManager's active term.gridElement.getSearchFields()— the DataManager's restricted search fields, ornullwhen unrestricted; consumed by<vn-grid-toolbar-search>'s tooltip (see "Built-in search item — search-fields tooltip" above).vn-grid-total-row-count-changedevent — dispatched whenever the attached DataManager pushes a new total row count (or it becomes unknown);detail.totalRowCountisnullwhen unknown.vn-grid-load-more-succeededevent — dispatched after every successful infinite-scroll page append;detailis{ rows, loadedRowCount, hasMoreRows }. Required for<vn-grid-toolbar-status>to track the loaded count during scroll-triggered pagination (requiresvanilla-grid1.13.0+).