ESL Core
Core consumer guide for @exadel/esl: base classes, decorators, traversing queries, event system, class helpers, and media abstractions.
Use when: You need the main mental model for writing or reviewing ESL consumer code in an idiomatic way, including custom tags, mixins, attributes, events, and responsive behavior.
Preview skill content
Skill: ESL Core
Version target: This skill is written for ESL v6 consumer code.
When to use: You are writing or reviewing consumer code that uses @exadel/esl and need the core mental model of the library: base classes, registration, decorators, query helpers, events, media conditions, and built-in syntax sugar.
Primary goal: Generate idiomatic ESL code that follows the library's own patterns instead of falling back to raw DOM or framework-specific habits.
Public import rules
For consumer code, import from the package public entries under @exadel/esl/modules/.... Direct imports from @exadel/esl are also valid when the consumer project uses bundling/tree-shaking in a way that does not pull unnecessary code into the final bundle.
Typical imports:
import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
import {ESLMixinElement} from '@exadel/esl/modules/esl-mixin-element/core';
import {ESLTraversingQuery} from '@exadel/esl/modules/esl-traversing-query/core';
import {ESLMediaQuery, ESLMediaRuleList} from '@exadel/esl/modules/esl-media-query/core';
import {attr, boolAttr, jsonAttr, prop, listen, ready} from '@exadel/esl/modules/esl-utils/decorators';
Rules:
- Prefer public
coreentries. - Root import from
@exadel/eslis acceptable in tree-shaken setups. - Do not import from internal implementation files or repository-only paths.
- For most day-to-day work inside an ESL component, prefer the built-in
$$*shortcuts over re-wiring low-level utilities manually.
ESL mental model
ESL has two base component types with almost the same authoring style:
| Type | Base class | What it is | Host element |
|---|---|---|---|
| Custom tag | ESLBaseElement | A real custom element (<my-element>) | this |
| Custom attribute / mixin | ESLMixinElement | Behavior attached via attribute (<div my-mixin>) | this.$host |
Shared day-to-day API:
$$find,$$findAll$$cls$$attr$$fire$$on,$$off$$error@listen- attribute decorators like
@attr,@boolAttr,@jsonAttr
The main difference is where those APIs act:
- in
ESLBaseElementthey target the element itself - in
ESLMixinElementthey target the mixin host ($host)
ESLBaseElement and ESLMixinElement
ESLBaseElement
Use when you need a new HTML tag with its own DOM lifecycle.
import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
import {attr, boolAttr, jsonAttr, listen} from '@exadel/esl/modules/esl-utils/decorators';
export class MyElement extends ESLBaseElement {
static override is = 'my-element';
@attr({defaultValue: ''}) public title: string;
@boolAttr() public active: boolean;
@jsonAttr({defaultValue: {}}) public config: Record<string, unknown>;
protected override connectedCallback(): void {
super.connectedCallback();
// init logic
}
protected override disconnectedCallback(): void {
// cleanup before super if needed
super.disconnectedCallback();
}
@listen('click')
protected _onClick(e: MouseEvent): void {
// ...
}
}
MyElement.register();
ESLMixinElement
Use when you need to attach behavior to an existing element via an attribute.
import {ESLMixinElement} from '@exadel/esl/modules/esl-mixin-element/core';
import {attr, boolAttr, jsonAttr, listen} from '@exadel/esl/modules/esl-utils/decorators';
export class MyMixin extends ESLMixinElement {
static override is = 'my-mixin';
static override observedAttributes = ['title'];
@attr({defaultValue: ''}) public title: string;
@boolAttr() public active: boolean;
@jsonAttr({defaultValue: {}}) public config: Record<string, unknown>;
protected override connectedCallback(): void {
super.connectedCallback();
// init logic on this.$host
}
protected override disconnectedCallback(): void {
super.disconnectedCallback();
}
@listen('click')
protected _onClick(e: MouseEvent): void {
// this.$host is the real DOM element
}
}
MyMixin.register();
Key differences
| Topic | ESLBaseElement | ESLMixinElement |
|---|---|---|
| Registration | customElements.define(...) via register() | ESLMixinRegistry via register() |
| Host | this | this.$host |
| HTML form | <my-element> | <div my-mixin> |
static is | custom tag name | activation attribute name |
| Multiple per same host | no | yes |
| Primary observation | native custom element lifecycle | attribute-driven attach/detach |
Registration rules
- Set
static isbefore callingregister(). ESLBaseElement.register()optionally accepts a tag name, but the normal consumer path is definingstatic isin the class.- Custom element tag names and mixin
isattributes must contain a dash to comply with custom element naming rules. - Do not mutate
isafter registration.
Lifecycle rules
- Always call
super.connectedCallback(). - Always call
super.disconnectedCallback(). @readyis optional. It does not define component readiness; it defers method execution until the DOM is ready (DOMContentLoaded) and the next task, which is useful when DOM lookup must wait for the parsed tree.attributeChangedCallbackreacts only to observed attributes.- For
ESLBaseElement, that means attributes listed instatic observedAttributes. - For
ESLMixinElement, that means attributes listed instatic observedAttributes, plus the primary mixinisattribute, which is always observed. - When triggered,
attributeChangedCallbackmay still run on every write, not only on actual value change. - Guard expensive reactions when needed:
class Example extends ESLBaseElement {
protected override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue) return;
// real change handling
}
}
Shared component API ($$* shortcuts)
Inside both ESLBaseElement and ESLMixinElement you can use:
| API | Meaning |
|---|---|
$$find(sel) | ESLTraversingQuery.first(sel, host) |
$$findAll(sel) | ESLTraversingQuery.all(sel, host) |
$$cls(cls, value?) | read/toggle CSS classes on host |
$$attr(name, value?) | read/set/remove host attribute |
$$fire(name, init?) | dispatch custom event |
$$on(...) | subscribe with ESL event system |
$$off(...) | unsubscribe with ESL event system |
$$error(err, key) | default logger used by @safe |
Use these helpers first. They already encode ESL conventions.
ESLTraversingQuery
ESLTraversingQuery extends normal CSS selection with relative traversal syntax. It powers $$find and $$findAll.
This section describes the behavior and conventions expected in ESL v6 consumer code.
Why it matters
It is used everywhere in ESL because many components need to resolve targets relative to the current host, not only by global CSS selectors.
Important syntax
- plain CSS selector:
'.item.active' - empty query
''— returns the current base element / host ::next— next sibling::prev— previous sibling::parent— direct parent::parent(.panel)— closest parent matching selector::closest(.panel)— closest ancestor including current element::child(button)— direct child elements::find(.item)— descendants::first,::last,::nth(2)— result limiting::visible— visible elements only::not([hidden])— post-filtering::filter(:first-child)— post-filtering
Examples
this.$$find(''); // current host: this for element, this.$host for mixin
this.$$find('::parent');
this.$$find('::closest(esl-panel)');
this.$$find('::find(button, a)::not([hidden])');
this.$$findAll('::find(.row)::visible');
Difference from querySelector
- can start from the current host without repeating selectors
- supports traversal tokens like
::parent,::closest,::next - is designed for component-relative targeting, not just document-wide CSS lookup
Important nuance:
this.$$find('button')is a plain CSS query and behaves like a normal scoped/global selector lookup for the current query scope.- If you want an explicitly host-relative descendant search, prefer
this.$$find('::find(button)')orthis.$$findAll('::find(button)').
Do not treat $$find / $$findAll as a ban on native DOM APIs:
this.querySelector(...)/this.querySelectorAll(...)are still completely valid insideESLBaseElementwhen a normal element-scoped CSS query is enough.- Prefer
$$find/$$findAllwhen you need ESL traversing syntax, when the selector comes from component API, or when you want richer relative targeting such as::parent,::closest(...), or::find(...).
Prefer $$find / $$findAll in ESL components instead of raw querySelector when the target is part of the component relationship model.
Attribute and property decorators
These decorators are host-aware:
- in an element they work on
this - in a mixin they work on
this.$host
That means the same decorator patterns are reusable in both component types.
@attr
Generic property-to-attribute mapping.
Use it for:
- strings
- numbers
- tri-state booleans
- inherited values
- custom parsing/serialization
import {attr} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
@attr({defaultValue: ''}) public title: string;
@attr({
defaultValue: true,
parser: (v) => v !== 'false',
serializer: (v) => v ? '' : null,
})
public closable: boolean;
}
Capabilities:
- custom attribute name
data-*attributesreadonlydefaultValue- custom parser/serializer
- inheritance from ancestors
@boolAttr
Boolean presence attribute.
import {boolAttr} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
@boolAttr() public disabled: boolean;
}
Semantics:
- attribute present →
true - attribute absent →
false
Use @attr, not @boolAttr, when you need a default-enabled or tri-state boolean.
@jsonAttr
Object mapping decorator.
import {jsonAttr} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
@jsonAttr({defaultValue: {theme: 'light'}})
public config: {theme: string};
}
Important: in current ESL it supports not only strict JSON but a relaxed object syntax suitable for HTML attributes.
Examples it can parse:
<my-element config='{"theme":"dark"}'></my-element>
<my-element config="{theme: 'dark', compact: true}"></my-element>
<my-element config="theme: 'dark'; compact: true"></my-element>
Think of it as JSON-like / config-like object syntax, not just strict JSON.
@prop
Prototype-level shared property or provider-backed property.
Use it to:
- define shared constants
- define provider-backed values
- override inherited
@attr/@boolAttr/@jsonAttrmappings in subclasses
import {attr, prop} from '@exadel/esl/modules/esl-utils/decorators';
class BasePanel extends ESLBaseElement {
@attr({defaultValue: 'info'}) public kind: string;
}
class WarningPanel extends BasePanel {
@prop('warning', {readonly: true}) public override kind: string;
}
Property providers
A provider is a function that receives the host as both this and argument.
(that) => that.someValue
Providers are important in ESL because they allow a value to be resolved from the current component context, including cases where a mixin reads from its host state.
Most common provider use cases:
@listenfields such as dynamicevent,target,selector, orcondition@attr({defaultValue: (...) => ...})@prop((that) => ...)
This is also the main way to pass the current instance into decorator configuration.
Event model: @listen vs $$on / $$off
@listen
Use @listen for class-level declarative event listeners.
import {listen} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
@listen('click')
protected _onClick(e: MouseEvent): void {}
@listen({event: 'keydown', target: 'window'})
protected _onKeydown(e: KeyboardEvent): void {}
@listen({event: 'click', selector: '.btn'})
protected _onBtnClick(e: MouseEvent): void {}
}
Key idea:
- metadata is declared on the method
- ESL auto-subscribes on connect
- ESL auto-unsubscribes on disconnect
Use @listen by default for stable listeners that belong to the component class.
$$on / $$off
Use them for manual or dynamic subscription control.
class Example extends ESLBaseElement {
@listen({event: 'resize', target: 'window', auto: false})
protected _onResize(): void {}
protected override connectedCallback(): void {
super.connectedCallback();
this.$$on(this._onResize);
}
protected override disconnectedCallback(): void {
this.$$off(this._onResize);
super.disconnectedCallback();
}
}
Use manual API when:
- the listener is conditional
- the target changes at runtime
- the event type changes at runtime
- you need to temporarily re-bind a handler
Mental split
@listen= declarative class contract$$on/$$off= imperative runtime control
esl-event-listener ecosystem
ESL event handling is more powerful than raw addEventListener / removeEventListener.
Why it matters
It supports:
- declarative listeners
- delegation
- target indirection
- bulk unsubscribe by criteria
- subscriptions without keeping the original callback manually
- EventTarget adapters for observers and gestures
Important concepts
- descriptors are attached to handlers
- listeners are stored relative to a host object
- unsubscription can use criteria like event name, handler, target, or group
Useful targets/adapters
These can be used directly in @listen or manual subscriptions:
ESLDecoratedEventTarget.for(target, decorator, ...args)- wraps an
EventTargetwith debounce/throttle-like behavior
- wraps an
ESLResizeObserverTarget.for(el)- gives
resizeevents fromResizeObserver
- gives
ESLIntersectionTarget.for(el, settings?)- gives
intersectionevents fromIntersectionObserver
- gives
ESLSwipeGestureTarget.for(el, settings?)- gives
swipeevents
- gives
ESLWheelTarget.for(el, settings?)- gives
longwheelevents
- gives
Example:
import {listen} from '@exadel/esl/modules/esl-utils/decorators';
import {ESLMediaQuery} from '@exadel/esl/modules/esl-media-query/core';
class Example extends ESLBaseElement {
@listen({event: 'change', target: ESLMediaQuery.for('@-sm')})
protected _onViewportChange(): void {}
}
Prefer ESL event adapters when the library already exposes the observer/gesture model you need.
CSSClassUtils and $$cls
$$cls is the component-facing shortcut for host class management.
Basic use
this.$$cls('active'); // check
this.$$cls('active', true); // add
this.$$cls('active', false); // remove
Supported token behavior
CSSClassUtils supports:
- space-separated class lists:
'a b c' - inversion with
!token - locker-aware low-level class management in the utility itself
Examples:
this.$$cls('open selected', true);
this.$$cls('hidden', false);
// inversion example on the low-level utility
CSSClassUtils.add($el, '!hidden'); // removes 'hidden'
CSSClassUtils.remove($el, '!hidden'); // adds 'hidden'
Key distinction from raw classList:
- component code can work with token strings instead of multiple separate operations
- the same syntax is used pervasively across ESL code
For component authoring, $$cls(...) is usually the shortest and most ergonomic host-level API, especially when class tokens come from configuration or component API.
If you are operating on non-host elements, using CSSClassUtils directly is also fully valid.
ESLMediaQuery and ESLMediaRuleList
ESLMediaQuery
Extended media condition object compatible with the event system.
Features:
- native media query conditions
- breakpoint shortcuts like
@xs,@md,@+lg,@-sm - DPR shortcuts like
@x2 - environment shortcuts like
@mobile,@ios,@safari - dynamic shortcuts through the media shortcut registry
- tolerant parsing for logical combinations
- works as
EventTarget - dispatches change events with media/match information when the condition changes
Example:
import {ESLMediaQuery} from '@exadel/esl/modules/esl-media-query/core';
const mq = ESLMediaQuery.for('@md and @desktop');
if (mq.matches) {
// desktop medium-and-up behavior
}
And with listeners:
class Example extends ESLBaseElement {
@listen({event: 'change', target: ESLMediaQuery.for('@-sm')})
protected _onMediaChange(): void {}
}
Practical example: a component can listen to a reduced-motion-related shortcut or condition and adapt animation behavior to user preferences without wiring raw matchMedia listeners manually.
ESLMediaRuleList
Maps media rules to values.
Useful when one attribute/config value should change by media condition.
Examples:
ESLMediaRuleList.parse('default | @xs => compact | @+md => full');
ESLMediaRuleList.parse('@xs => {gap: 8} | @+md => {gap: 16}', ESLMediaRuleList.OBJECT_PARSER);
// tuple format: values and queries are passed separately
ESLMediaRuleList.parse('1|2|3', '@xs|@md|@lg');
Use ESLMediaRuleList when the problem is not just “does this query match?” but “what value should be active under current conditions?”.
It supports both:
- arrow-rule format:
default | @xs => compact | @+md => full - tuple format:
values,queries
Related decorators worth knowing
These are not the main focus of ESL Core, but often appear in real code:
@ready— defer execution until the DOM is ready (DOMContentLoaded) and the next task@bind— lazy per-instance method binding@decorate— wrap methods with debounce/throttle-like decorators@memoize— cache getter/method results@safe— catch sync errors and report through$$error
Common mistakes to avoid
- Importing from repository internals instead of public
@exadel/esl/modules/.../corepaths. - Treating
ESLBaseElementandESLMixinElementas separate ecosystems instead of one shared model with different hosts. - Forgetting
register(). - Forgetting
super.connectedCallback()/super.disconnectedCallback(). - Using plain CSS lookup where ESL traversing syntax would better express a component relationship or a user-provided target API.
- Using raw
addEventListenerfor static class-owned listeners instead of@listen. - Using
@boolAttrwhen a tri-state or inherited value actually requires@attr. - Assuming
@jsonAttraccepts only strict JSON. - Forgetting that mixin logic acts on
$host, not on the mixin instance as a DOM node.
Practical rule of thumb
When generating ESL consumer code:
- Choose the host model first: tag or mixin.
- Import from public
coreentrypoints. - Use decorators for attribute/state mapping.
- Use
@listenfor stable listeners. - Use
$$on/$$offfor dynamic listeners. - Use
$$find/$$findAllfor component-relative lookup. - Use
$$cls/$$attrfor host state reflection. - Reach for
ESLMediaQuery/ESLMediaRuleListwhen responsiveness is part of the API.