The shared foundation for KeenMate's web components. It consolidates the
custom-element plumbing that five shipping components (web-multiselect,
web-daterangepicker, web-treeview, web-dropzone, web-grid) each used to
re-implement — a reactive input model, a base element class, converters,
categorized logging, and the window.components global — into one package so
that correctness lives in a single place and can't drift.
docs/SPEC.mdis the source of truth for design intent. This README is the tour; read the relevant SPEC section before changing behavior.
New /search module, no changes to existing APIs. Release notes
cover the API and how a component adopts it.
- Shared search —
@keenmate/web-components-core/search. The search rules web-multiselect 2.6.0-rc02 shipped now live in core, so every component with a search box matches and highlights the same way. Pure, DOM-free functions, no dependencies, on a new subpath (not re-exported from the root). - Normalization and match modes.
normalizeSearchTexthandlesnormalized(default: ignores case and diacritics, foldsł ø đ ß æ œ…),case-insensitive, andexact. Match modes arecontains,equals,starts-with,ends-with,word-starts-with,word-ends-with, andregex.prepareSearchTermandcreateRegexCompiler(which keeps the last valid typed pattern) build the term;matchesSearchTermtests it. - Highlight mapping you can render safely.
findHighlightRangesreturns{ text, ranges }, with ranges indexing into the returned NFC text, andwrapRangeswraps them with your markup and escaping. Also exports the sharedsearchCallback(term, signal, context)types. See SPEC §12.11 and the liveexamples-search.htmldemo.
See CHANGELOG.md for the full list.
- Theme context — dark mode that works in Firefox and Safari. Components followed a page's
dark/light signal with
:host-context(.dark), which Firefox doesn't support and Safari only partly, so a Tailwind<html class="dark">page got a light component there. Core now detects the signal once per page (data-theme, thendata-bs-theme, then.dark/.light, on<html>then<body>) and mirrors it onto everyBlissElementhost asdata-context-theme="dark|light", which plain:host([data-context-theme="dark"])CSS matches in every browser. One cached value and one shared observer serve every component; a component is notified only when its result changes. - Force it globally or per component.
setThemeMode('dark' | 'light' | 'auto')forces every component (detection pauses while forced);data-themeon one component still wins. theme-sourcefor sections. EveryBlissElementaccepts atheme-sourceattribute /themeSourceproperty — a selector,closest, or anElement— to follow a section (e.g. a Bootstrapdata-bs-themewrapper) instead of the page. Components that need JS-side theming override the newthemeChanged(scheme)hook. See SPEC §12.10 and the liveexamples-theme.htmldemo.⚠️ Note for component authors. A dark wrapper<div>is no longer picked up implicitly (only Chromium ever did); usetheme-source="closest"there.observedAttributesnow always includestheme-source.
See CHANGELOG.md for the full list.
npm install @keenmate/web-components-core
Runtime dependencies: a single one — @floating-ui/dom, used only by the
/positioning subpath, so it stays out of the base import graph unless you
position. The logging engine is vendored (no loglevel dependency).
Every public input — attribute, complex property, or callback — is a single
InputDef row. There is no central switch statement: dispatch is just
def.converter.fromAttribute(raw). A new input type is a new converter created
anywhere (core or a component), never a core edit.
import { BlissElement, toEnum, toInt, toText, toFunction, type InputDef } from '@keenmate/web-components-core';
const INPUTS: readonly InputDef[] = [
{ configKey: 'selectionMode', attribute: 'selection-mode',
converter: toEnum(['single', 'multiple'], { default: 'single' }), on: 'reinit' },
{ configKey: 'maxHeight', attribute: 'max-height',
converter: toText({ default: '20rem' }), on: 'update' },
{ configKey: 'minSearchLength', attribute: 'min-search-length',
converter: toInt({ min: 0, default: 1 }), on: 'update' },
// property-only (no attribute): callbacks and rich data
{ configKey: 'getValueCallback', converter: toFunction(), on: 'reinit' },
];Each row declares what a change triggers via on:
update— patch in place (default)reinit— requires a full rebuildnone— store only, don't react (e.g. event callbacks)
One converter per input handles the attribute path (fromAttribute: raw string
→ validated V), the property path (validate: is this JS-assigned value
acceptable?), and optional reflection (toAttribute). Correctness lives in one
place so it can't drift across the three spots it used to.
The to* factory library sets both paths from the same inputs:
| factory | for |
|---|---|
toEnum(values, { default?, shouldNullOnInvalid? }) |
fixed string set |
toInt({ min?, max?, default? }) / toFloat(...) |
numbers |
toText({ shouldTrim?, isEmptyAllowed?, isNullable?, default? }) |
strings (isNullable → optional string, absent/empty ⇒ null) |
toBool('presence' | 'default-true' | 'default-false' | 'tristate') |
booleans |
toBytes({ default? }) |
human sizes ("10mb") |
toList({ itemType?, separator?, requiredCount?, shouldTrim?, default? }) |
delimited lists |
toCustom(parse, { validate?, toAttribute? }) |
bespoke parsers |
toFunction() |
property-only callbacks |
toValue({ validate?, default? }) |
rich value (property = validate, attribute = JSON) |
toObjectArray({ validateItem?, default? }) |
array of rich items (e.g. options); default [] |
toObject({ validate?, default? }) |
plain non-array object |
Boolean options follow the house convention (
is/should/has/canprefixes):shouldTrim,isEmptyAllowed,isNullable,shouldNullOnInvalid.
BlissElement wires both entry points (attribute + property) through the same
pipeline, so everything is reactive by construction. It is SSR-safe, computes
observedAttributes from the input table, coalesces bursts into a single update,
and provides typed event dispatch. The input table is opt-in — a subclass
without static inputs still gets the SSR-safe base plus typed events (emit() /
on()), which is how web-grid uses it while keeping its own config-object API.
class WebMultiSelect extends BlissElement {
protected static override inputs = INPUTS;
protected override reinit() { /* full rebuild — reads this.config */ }
protected override update(partial: Record<string, unknown>) { /* patch just the changed keys */ }
protected override connect() { /* start listeners / observers / timers */ }
protected override disconnect() { /* stop what connect() started */ }
}Lifecycle (build-once + activate/deactivate, Lit's model):
reinit()— first connect, and any batch touching anon: 'reinit'input. In a mixed batch, reinit dominates (it rebuilds from fullthis.config, so theupdatepartial is suppressed).update(partial)— a batch with onlyon: 'update'changes.connect()/disconnect()— every connect/disconnect, for live resources. A DOM move re-activates without rebuilding, so transient UI state survives. Keep them balanced.
Batch many changes into one reinit/update with setAttributes({ ... }) or
batch(() => { ... }). To await the pipeline deterministically — in a test or
before reading rendered state — use await el.whenSettled(): it resolves
once every staged change has flushed and its reinit()/update() has run
(immediately when nothing is pending).
Components that swap a floating panel for a fullscreen sheet (or a modal) on smaller devices share one signal instead of each re-deriving "what is a phone."
React to the device by overriding environmentChanged(env) — the base
subscribes on connect and unsubscribes on disconnect (no listeners unless you
override it), fires immediately with the current snapshot, then on every
pointer / hover / orientation / viewport change. For a one-off synchronous read,
call getEnvironment().
Classify, then present — two separate concerns:
classifyDevice(env)→'mobile' | 'tablet' | 'desktop'is the shared classification every component must agree on. Capability decides first: a non-touch device is alwaysdesktopat any width — so a narrowed desktop window keeps its floating dropdown, never a fullscreen sheet. Only touch devices consult the 600px short-side line (mobilebelow it,tabletat/above). This is a different axis from the width-onlyenv.breakpoint(a landscape iPad isbreakpoint: 'desktop'yetdeviceClass: 'tablet').resolvePresentation(mode, env, map?)→'floating' | 'modal' | 'fullscreen'is the per-component policy.modeis the author setting ('auto'|'floating'|'modal'|'fullscreen'); a forced value wins, and'auto'classifies the device and looks it up inmap, falling back per-class toDEFAULT_PRESENTATION_MAP(mobile → fullscreen, tablet/desktop →floating). You list only the classes you change:
protected override environmentChanged(env: EnvironmentSnapshot) {
// "on a tablet, use a modal" — phone & desktop keep the defaults:
this.setPresentation(resolvePresentation(this.mode, env, { tablet: 'modal' }));
}Need a rule finer than the class — e.g. "desktop, but under 800px show a
modal"? The map is class-keyed, so drop to classifyDevice(env) and read the
current width off the snapshot yourself (env.viewportWidth):
protected override environmentChanged(env: EnvironmentSnapshot) {
if (this.mode !== 'auto') return this.setPresentation(this.mode);
const cls = classifyDevice(env);
this.setPresentation(
cls === 'mobile' ? 'fullscreen' :
cls === 'tablet' ? 'modal' :
env.viewportWidth < 800 ? 'modal' : // desktop, narrow
'floating', // desktop, wide
);
}Rule of thumb: class-only policy → resolvePresentation(mode, env, map);
width-aware → classifyDevice(env) + your own branch. For the fullscreen tier,
lockBodyScroll() and observeKeyboardInset(panel) handle page-scroll locking
and keeping the sheet above the soft keyboard. See SPEC §12.9 for
the full rationale (why 600px, capability over width, the deprecated
resolveMobilePresentation).
Every BlissElement host carries data-context-theme="dark|light" when the page
asks for a scheme, so component CSS needs no :host-context() (unsupported in
Firefox, partial in Safari):
:host([data-context-theme="dark"]) { color-scheme: dark; }
:host([data-context-theme="light"]) { color-scheme: light; }
/* per-instance forcing, declared last so it wins */
:host([data-theme="dark"]) { color-scheme: dark; }
:host([data-theme="light"]) { color-scheme: light; }Resolution, highest first: data-theme on the component (its CSS) →
setThemeMode('dark' | 'light') → the component's theme-source → the page
(<html>, then <body>: data-theme, data-bs-theme, .dark / .light) →
nothing (the inherited color-scheme applies). Detection runs once per
watched element and is cached; one shared MutationObserver per element
re-detects on a signal change, and components hear only real changes.
import { setThemeMode, getThemeContext } from '@keenmate/web-components-core';
setThemeMode('dark'); // force every component ('auto' = detect again)
getThemeContext(); // the detected page scheme: 'dark' | 'light' | null<my-el theme-source="closest"></my-el> <!-- nearest ancestor with a signal -->
<my-el theme-source=".admin-panel"></my-el> <!-- a chosen element -->Override themeChanged(scheme) for JS-side theming; static themeContext = false
opts a component out. See SPEC §12.10 and examples-theme.html.
registerComponent replaces the block every component used to copy-paste (and
drift). It defines the element, publishes build metadata + logging controls to
window.components[tag], and wires getInstances() to the live-instance
registry BlissElement maintains automatically (added on connect, removed on
disconnect — a DOM move re-tracks without duplicating).
import { registerComponent, createLoggers } from '@keenmate/web-components-core';
const logging = createLoggers('MULTISELECT'); // categories: INIT / DATA / UI
registerComponent('web-multiselect', WebMultiSelect, {
config: { name: '@keenmate/web-multiselect', version: '1.0.0', author: 'KeenMate' },
logging, // optional — flattened onto the global entry
// shouldAutoDefine: true (default) — also exposes an idempotent register()
});From anywhere (console, a devtools overlay, server-rendered code):
window.components['web-multiselect'].getInstances() // live elements on the page
window.components['web-multiselect'].version() // build version
window.components['web-multiselect'].logging.enableLogging()The returned elements are the per-instance handles — enumerate tags with
getRegisteredTags(), each tag's instances with getInstances(tag).
createLoggers(namespace, categories?) returns categorized
NAMESPACE:CATEGORY loggers over loglevel, each with a color-coded prefix.
Categories default to INIT / DATA / UI (redefinable / extendable).
enableLogging() / disableLogging() / setLogLevel() / setCategoryLevel()
control the whole bundle (i.e. all instances of that component type).
BlissElement also exposes this.log — an instance logger per category,
gated by the more verbose of the type-level level and this element's own
override, and emitted via console directly. This lets a devtools overlay make
one element loud while its type stays silent:
protected override update(partial: Record<string, unknown>) {
this.log.DATA?.debug('update', Object.keys(partial));
}el.enableLogging(); // this element only — even while the type is silent
el.disableLogging();
el.isLoggingEnabled; // booleanEach line is prefixed with a tag#id handle — the element's own id when set,
else a tag#n counter:
[MULTISELECT:DATA] web-multiselect#country-picker update ['maxHeight']
[MULTISELECT:DATA] web-multiselect#1 update ['searchPlaceholder']
The
tag#idhandle is computed once, on first access tothis.log, and cached for the element's lifetime. In practice the element'sidis set in markup before its first lifecycle log, so the label reflects it correctly.But if the
idattribute is assigned later — after the element has already logged once — the label keeps the value it had at first access (the counter, if there was noidthen). The element reference returned bygetInstances()is always the reliable handle; the string label is a convenience for reading the console. Set theidbefore the element first logs (i.e. in markup / before connection) if you want it to appear in the label.
Two zero-dep helpers (main index) for the shadow-root CSS plumbing components
re-roll. Core owns the injection, not the authoring rules (@layer order,
Vite ?inline) — and stays render-agnostic, so these are free functions, not
base-class methods.
import { adoptStyles, createStyleSlot } from '@keenmate/web-components-core';
import styles from './main.css?inline';
// static, shared across all instances (one cached CSSStyleSheet; <style> fallback)
adoptStyles(this.shadowRoot, styles);
// per-instance user CSS (the customStylesCallback pattern) — one replaceable slot
const slot = createStyleSlot(this.shadowRoot, { className: 'custom-styles' });
slot.set(this.config.customStylesCallback?.()); // re-set replaces; falsy clearsFloating-element positioning over a single pinned @floating-ui/dom — one
low-level anchor() primitive plus createTooltip() and createPopover()
presets, replacing the ~15 hand-rolled call sites across the components. A
separate subpath, so @floating-ui/dom is only pulled in when you position.
import { createPopover, createTooltip } from '@keenmate/web-components-core/positioning';
// dropdown/panel — portaled to <body>, width-matched, keeps the theme
const dropdown = createPopover({ reference: input, panel, matchWidth: 'min' });
dropdown.open(); // mount + position + autoUpdate
dropdown.close(); // unmount + stop
// tooltip — hover/focus with a delay; followCursor: true to track the pointer
const tip = createTooltip({ trigger, content: 'Help text', delay: { show: 200 } });anchor(floating, reference, opts) returns { update, destroy }; options:
placement (default 'bottom-start'), strategy (default 'fixed'), offset,
flip, shift, matchWidth: 'min' | 'exact', lockPlacement, autoUpdate,
inheritThemeFrom (copies data-theme onto portaled layers — C-CS-10), and a
platform escape hatch.
Built-in search rules shared by every component with a search box: normalization
(search-behavior), where the term matches (search-match), term preparation, and highlight
mapping. Pure functions, no dependencies; the component keeps its per-item cache.
import {
createRegexCompiler, findHighlightRanges, matchesSearchTerm, normalizeSearchText,
prepareSearchTerm, wrapRanges,
} from '@keenmate/web-components-core/search';
const behavior = 'normalized', match = 'word-starts-with';
const prepared = items.map((i) => normalizeSearchText(i.label, behavior)); // once, not per keystroke
const regex = createRegexCompiler(); // one per instance
const term = prepareSearchTerm(typed, { behavior, match, regex }); // 'kru' → { kind: 'text', text: 'kru' }
const hits = items.filter((i, n) => matchesSearchTerm(term, match, prepared[n], i.label));
const { text, ranges } = findHighlightRanges('Český Krumlov', term, { behavior, match });
wrapRanges(text, ranges, { escape }); // 'Český <mark>Kru</mark>mlov'normalized (default) ignores case and diacritics (ces → Český, strasse → Straße),
case-insensitive ignores case only, exact nothing. Match modes: contains, equals,
starts-with, ends-with, word-starts-with, word-ends-with, regex (raw value, no
normalization). Always render the text findHighlightRanges returns — it is the NFC form the
ranges index into. Also exports the searchCallback context convention types
(SearchCallbackContext, SearchEntry). See SPEC §12.11.
Runner-agnostic DOM fixtures (pure DOM, zero deps — vitest+jsdom or a real
browser). Pair them with await el.whenSettled() to await the reactive pipeline.
import { mount, cleanup, listen, uniqueTag, defineOnce } from '@keenmate/web-components-core/testing';
afterEach(cleanup);
it('emits select when picked', async () => {
const tag = defineOnce(uniqueTag(), MyElement);
const el = mount<MyElement>(`<${tag} selection-mode="multiple"></${tag}>`);
const spy = listen(el, 'select');
el.pick('a');
await el.whenSettled();
expect(spy.lastDetail).toEqual({ option: 'a' });
});mount / cleanup, mountBeforeUpgrade (pre-upgrade property capture),
uniqueTag / defineOnce, nextTick / nextFrame, and listen → an
EventSpy (count / events / last / lastDetail / stop()).
A Custom Elements Manifest
analyzer plugin that reads the static inputs / static events tables, so the
manifest — and the HTML autocomplete/hover editors build from it — is generated
from the single source of truth: structure from each row + its converter
(attribute↔property, type, default, reflect, enum members), prose from
optional description / deprecated fields on the row. Build-time only.
// custom-elements-manifest.config.js
import { blissAnalyzerConfig } from '@keenmate/web-components-core/cem';
export default blissAnalyzerConfig();// a row carries its own help text — no @attr re-declaration to drift
{ configKey: 'selectionMode', attribute: 'selection-mode',
converter: toEnum(['single', 'multiple'], { default: 'single' }),
description: 'Whether the user can pick one option or several.' }npm test # vitest run (jsdom) — the whole suite
npm run test:watch # vitest watch mode
npm run typecheck # tsc --noEmit
npm run build # tsc → dist/ (ESM + .d.ts), excludes *.test.ts
Tests (*.test.ts) live next to the code they cover. Source uses explicit .js
extensions on relative imports (moduleResolution Bundler) and
verbatimModuleSyntax (type-only imports must use import type).
docs/reactivity-and-batching.md— how the pipeline coalesces bursts,reinit()vsupdate(), how many times a component rebuilds, and the mass-update tools (setAttributes/batch).docs/SPEC.md— full design intent, the per-component divergence this consolidates, and the decisions log.CHANGELOG.md— what's landed.
MIT