ESL Consumer Skill
Canonical AI skill bundle for @exadel/esl consumers, shipped together with the published package and backed by bundled reference docs.
Use when: You want one installable ESL skill that teaches an AI agent the correct host model, public imports, decorators, traversal, events, and review criteria.
Download bundle archiveInstall with skills CLI
npx skills add exadel-inc/esl Recommended flow when your AI tooling can install skills directly from a published npm package.
Manual bundle files
Download the archive to get SKILL.md and the bundled references/ folder together. If you only need one file, use the per-file actions below.
Main skill
SKILL.md
Primary entry point loaded by AI tools.
Preview SKILL.md
ESL Consumer Code
You are generating or reviewing consumer code that uses @exadel/esl. Your goal is to produce idiomatic ESL code that uses the library primitives instead of raw DOM boilerplate. Use this skill for:
- custom elements based on
ESLBaseElement - mixins based on
ESLMixinElement - code using
@exadel/esl/modules/... - ESL decorators such as
@attr,@boolAttr,@jsonAttr,@listen - ESL event, traversal, media, and utility APIs If available, use reference docs in this skill package for deeper API details:
references/esl-core.mdreferences/esl-review.md
Public import rules
For consumer projects, prefer public module entrypoints:
import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
import {ESLMixinElement} from '@exadel/esl/modules/esl-mixin-element/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
@exadel/esl/modules/.../corepublic entries. - Root import from
@exadel/eslis acceptable only when the consumer setup supports tree-shaking appropriately. - Do not import from ESL internal implementation paths.
- Do not invent private paths when a public
coreentry exists.
Choose the correct host model first
Before writing code, decide the host model:
- Use
ESLBaseElementfor a new custom tag:
<my-element></my-element>
- Use
ESLMixinElementfor behavior attached to an existing element by attribute:
<div my-mixin></div>
Hard rule:
- In
ESLBaseElement, the DOM host isthis. - In
ESLMixinElement, the DOM host isthis.$host. Do not treat a mixin instance as the DOM element. For mixins, read and mutate the real element throughthis.$hostor host-aware ESL helpers.
Registration and lifecycle
Every ESL component or mixin must follow registration and lifecycle contracts:
export class MyElement extends ESLBaseElement {
static override is = 'my-element';
protected override connectedCallback(): void {
super.connectedCallback();
}
protected override disconnectedCallback(): void {
super.disconnectedCallback();
}
}
MyElement.register();
Rules:
- Set
static isbefore callingregister(). - Call
register()once for the class. - Always call
super.connectedCallback(). - Always call
super.disconnectedCallback(). - Do not mutate
static isafter registration. - Custom element and mixin names should contain a dash.
Prefer ESL decorators
Use decorators for attribute, property, and event wiring. Prefer:
@attrfor string, number, inherited, custom, default-enabled, or tri-state values@boolAttrfor boolean presence attributes only@jsonAttrfor object-like config attributes@propfor prototype-level constants or provider-backed values@listenfor stable class-owned event listeners@readyonly when logic must wait for DOM readiness Do not manually write repetitivegetAttribute,setAttribute,hasAttribute,addEventListener, orremoveEventListenerlogic when an ESL decorator or helper expresses the same behavior. Use@attr, not@boolAttr, when the value is not a simple presence boolean.
Use host-aware ESL helpers
Inside ESLBaseElement and ESLMixinElement, prefer host-aware shortcuts:
this.$$find(selector)this.$$findAll(selector)this.$$cls(className, state?)this.$$attr(name, value?)this.$$fire(eventName, init?)this.$$on(...)this.$$off(...)this.$$error(error, key?)These helpers target the correct host:thisforESLBaseElementthis.$hostforESLMixinElementUsethis.$$cls(...)andthis.$$attr(...)for host state reflection instead of directclassListor attribute operations on the host.
Event handling
Default choice:
- use
@listenfor stable listeners that belong to the class
@listen('click')
protected _onClick(e: MouseEvent): void {
}
Use $$on / $$off when:
- the listener is conditional
- the target changes at runtime
- the event type changes at runtime
- you need manual subscription control
@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();
}
Do not use raw addEventListener / removeEventListener for stable component-owned listeners when @listen can be used.
Traversing and DOM lookup
Use $$find / $$findAll when lookup is component-relative or user-configurable. ESL traversing syntax can express relationships such as:
''current host::parent::closest(.selector)::child(button)::find(.item)::next::prev::visibleExamples:
this.$$find('');
this.$$find('::closest(esl-panel)');
this.$$find('::find(button, a)::not([hidden])');
this.$$findAll('::find(.item)::visible');
Native querySelector is allowed when a simple element-scoped CSS lookup is enough, but prefer ESL traversing when the selector represents component relationships.
Media and responsive behavior
Prefer ESL media utilities over raw matchMedia wiring:
- use
ESLMediaQuerywhen the code needs to react to a media condition - use
ESLMediaRuleListwhen a config value changes by media condition
import {ESLMediaQuery} from '@exadel/esl/modules/esl-media-query/core';
@listen({event: 'change', target: ESLMediaQuery.for('@-sm')})
protected _onSmallViewportChange(): void {
}
Use ESLMediaRuleList for values like:
'default | @xs => compact | @+md => full'
Do not manually implement breakpoint parsing or media listener cleanup when ESL media utilities fit the problem.
Attribute change handling
When overriding attributeChangedCallback, guard expensive logic:
protected override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue) return;
super.attributeChangedCallback(name, oldValue, newValue);
}
Remember:
ESLBaseElementobserves attributes listed instatic observedAttributesESLMixinElementobservesstatic observedAttributesplus its own activation attribute
Common anti-patterns
Avoid:
- Importing from ESL internal implementation files.
- Choosing
ESLBaseElementwhen behavior should be a mixin. - Choosing
ESLMixinElementbut mutatingthisinstead ofthis.$host. - Forgetting
register(). - Forgetting
super.connectedCallback()orsuper.disconnectedCallback(). - Using raw event listeners for stable class-owned events.
- Using
@boolAttrfor default-enabled or tri-state values. - Assuming
@jsonAttraccepts only strict JSON. - Reimplementing class, attribute, traversal, media, or event helpers already provided by ESL.
- Making public API depend on internal ESL repository paths.
Minimal good examples
Custom element:
import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
import {attr, boolAttr, listen} from '@exadel/esl/modules/esl-utils/decorators';
export class AppCard extends ESLBaseElement {
static override is = 'app-card';
@attr({defaultValue: ''}) public heading: string;
@boolAttr() public selected: boolean;
protected override connectedCallback(): void {
super.connectedCallback();
this.$$cls('app-card-ready', true);
}
@listen('click')
protected _onClick(): void {
this.$$fire('app-card:click');
}
}
AppCard.register();
Mixin:
import {ESLMixinElement} from '@exadel/esl/modules/esl-mixin-element/core';
import {attr, listen} from '@exadel/esl/modules/esl-utils/decorators';
export class AppTrackClick extends ESLMixinElement {
static override is = 'app-track-click';
@attr({defaultValue: ''}) public eventName: string;
@listen('click')
protected _onClick(): void {
this.$$fire('app-track-click:trigger', {
detail: {eventName: this.eventName}
});
}
}
AppTrackClick.register();
Final checklist
Before returning ESL code, verify:
- Correct host model is used:
- custom tag →
ESLBaseElement - attribute behavior →
ESLMixinElement
- custom tag →
- Mixins use
this.$hostor host-aware$$*helpers for DOM host operations. - Imports use public
@exadel/esl/modules/...entries. static isis defined beforeregister().register()is present.- Lifecycle overrides call
super. - Attributes/properties use ESL decorators where appropriate.
- Stable listeners use
@listen. - Dynamic listeners use
$$on/$$offwith cleanup. - Component-relative lookup uses
$$find/$$findAllwhere appropriate. - Responsive logic uses
ESLMediaQueryorESLMediaRuleListwhere appropriate. - No internal ESL implementation paths are used.
- No raw DOM boilerplate duplicates an ESL primitive.
- The code is small, readable, and easy for a consumer project to extend.
Reference
references/esl-core.md
Core ESL mental model for authoring and reviewing consumer code.
Preview references/esl-core.md
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.
Reference
references/esl-review.md
Review checklist for host model, imports, lifecycle, decorators, and traversal.
Preview references/esl-review.md
Skill: ESL Review
Version target: This review skill is suitable for ESL 5+ consumer code.
When to use: You are reviewing consumer code that uses @exadel/esl and need to check whether it follows ESL's core patterns, public API boundaries, and idiomatic component authoring style.
Primary goal: Catch code that technically works but ignores ESL conventions, bypasses built-in helpers, or misuses the element/mixin model.
Review mindset
Review ESL code through these questions:
- Is the correct host model used (
ESLBaseElementvsESLMixinElement)? - Are imports taken from public
@exadel/eslentrypoints? - Does the code use ESL primitives (
@attr,@listen,$$find,$$cls,ESLMediaQuery) instead of re-implementing them manually? - Does lifecycle code preserve ESL auto-subscription / auto-cleanup behavior?
- Is responsive or event-driven behavior expressed in the ESL way rather than raw low-level APIs?
1. Host model correctness
Good signals
- New custom tag extends
ESLBaseElement. - Attribute-driven behavior extends
ESLMixinElement. - Mixin code consistently works through
$host. - The code uses the shared
$$*APIs instead of treating mixins as DOM nodes.
Review questions
- Should this be a tag or a mixin?
- Is a mixin chosen only because behavior must attach to an existing element?
- Does mixin code accidentally use
thiswherethis.$hostis the real DOM target?
Common issue
A mixin is written like a custom element and manipulates this.classList, this.querySelector, or this.dispatchEvent directly.
Preferred direction
Use:
this.$$cls(...)this.$$find(...)this.$$fire(...)this.$$attr(...)
These already target the correct host semantics.
2. Public import boundaries
Good signals
- Imports come from public
@exadel/esl/modules/.../coreentries or from root@exadel/eslin a tree-shaken setup. - Consumer code does not reference repository internals.
Review questions
- Is the code importing a public entry or an implementation detail?
- Is the import stable for npm consumers?
Red flags
- imports from internal subfolders or repository-only paths
- imports that bypass public package entrypoints entirely
Preferred direction
Use public package entries such as:
import {ESLBaseElement} from '@exadel/esl/modules/esl-base-element/core';
import {ESLMixinElement} from '@exadel/esl/modules/esl-mixin-element/core';
import {attr, boolAttr, jsonAttr, prop, listen} from '@exadel/esl/modules/esl-utils/decorators';
// or, in tree-shaken setups
import {ESLBaseElement, listen} from '@exadel/esl';
3. Registration and lifecycle
Good signals
static isis declared in the class.register()is called.super.connectedCallback()is preserved.super.disconnectedCallback()is preserved.
Review questions
- Was registration forgotten?
- Is lifecycle code preserving ESL auto-subscription behavior?
- Is
attributeChangedCallbackdoing expensive work on redundant writes?
Red flags
- missing
register() super.connectedCallback()omittedsuper.disconnectedCallback()omitted- custom lifecycle logic that breaks auto-subscribe / auto-unsubscribe assumptions
Preferred direction
Guard attribute-change logic when necessary:
class Example extends ESLBaseElement {
protected override attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue) return;
// real update logic
}
}
4. Attribute and property modeling
Good signals
@attrused for typed attribute-backed properties.@boolAttrused for presence booleans.@jsonAttrused for object-like config attributes.@propused for shared constants or overriding inherited attribute mappings.
Review questions
- Is the chosen decorator the simplest correct one?
- Should a boolean be
@boolAttr, or is it really tri-state and better modeled with@attr? - Is object config manually parsed even though
@jsonAttralready exists? - Is subclass behavior overriding an inherited attribute mapping in a clean way?
Red flags
- manual
getAttribute/setAttributeboilerplate for simple cases - parsing JSON manually in component code without a strong reason
- using
@boolAttrfor a value that needs a default-enabled or explicit-false model - using instance fields where
@propwould better express a shared constant
Preferred direction
- Prefer decorators over manual attribute plumbing.
- Prefer
@propwhen the goal is to replace inherited attribute-backed behavior with a fixed or provider-based value. - Remember that
@jsonAttrsupports relaxed JSON-like config syntax, not only strict JSON.
5. Event model and listener ownership
Good signals
- Stable class-owned listeners use
@listen. - Dynamic or conditional listeners use
$$on/$$off. - Listener targets and delegation are expressed declaratively when possible.
Review questions
- Should this listener be declarative?
- Is manual subscription really necessary here?
- Does the code rely on raw DOM event wiring where ESL already gives a better abstraction?
- Are re-subscriptions handled correctly when target/event/selector is dynamic?
Red flags
- raw
addEventListener/removeEventListenerfor stable component listeners - manual cleanup forgotten
@listen({auto: false})declared but never actually subscribed- dynamic event/target/selector changes without unsubscribe + resubscribe
Preferred direction
@listenfor stable listeners.$$on/$$offfor runtime control.- Use handler-reference unsubscription when refreshing dynamic listeners.
6. Traversal and target resolution
Good signals
$$find/$$findAllused for component-relative queries.ESLTraversingQuerysyntax is used when the relationship is structural (::parent,::closest,::find,::next,::prev).
Review questions
- Is this really a plain CSS lookup, or is it a component-relative relationship?
- Would
$$findwith traversing syntax make the intent clearer?
Red flags
- verbose
closest/parentElement/querySelectorchains for patterns already expressible viaESLTraversingQuery - mixing global document lookup into code that should stay host-relative
Preferred direction
Prefer concise traversing queries for component relationships.
7. Host state reflection
Good signals
$$clsis used for component-driven class reflection.$$attris used for direct attribute reflection when decorators are not the right fit.- State naming and reflection are consistent.
Review questions
- Is this host-state reflection part of the component contract?
- Would
$$cls/$$attrexpress the intent more clearly than raw DOM mutation?
Red flags
- repetitive
classList.add/removeorsetAttribute/removeAttributesequences for simple component state - class reflection scattered across unrelated methods without a clear model
Preferred direction
Use $$cls and $$attr where the code is expressing component state on the host.
8. Media and responsive logic
Good signals
ESLMediaQueryis used when responsiveness is part of the component behavior.ESLMediaRuleListis used when media conditions map to values/configs.- media conditions are treated as observable sources through the ESL event layer.
Review questions
- Is responsive behavior manually reimplemented with window resize checks?
- Should this value be modeled as a media-rule list instead of imperative if/else code?
Red flags
- manual
matchMediaplumbing whereESLMediaQuerywould be clearer - manual resize logic for value switching that belongs to
ESLMediaRuleList - component code hardcoding breakpoint logic in many places instead of centralizing it
Preferred direction
Prefer ESLMediaQuery / ESLMediaRuleList when responsiveness is part of the API rather than a one-off imperative detail.
9. esl-event-listener ecosystem usage
Good signals
- observer/gesture behavior uses ESL adapters where available
- event targets are reused through built-in wrappers
- the code takes advantage of
EventTargetcompatibility
Review questions
- Is the code manually managing
ResizeObserver,IntersectionObserver, swipe, or wheel state where ESL already exposes an adapter? - Could the listener become simpler if an ESL target wrapper was used?
Red flags
- custom wrapper code around observers already supported by ESL
- bespoke debounce/throttle event target logic instead of
ESLDecoratedEventTarget
Preferred direction
Prefer existing ESL adapters such as:
ESLResizeObserverTargetESLIntersectionTargetESLSwipeGestureTargetESLWheelTargetESLDecoratedEventTarget
Short review checklist
Before approving ESL consumer code, verify:
- [ ] Correct host model:
ESLBaseElementvsESLMixinElement - [ ] Public imports only
- [ ]
static isandregister()are correct - [ ] Lifecycle preserves
super.connectedCallback()/super.disconnectedCallback() - [ ] Attribute state uses decorators instead of unnecessary manual plumbing
- [ ] Stable listeners use
@listen - [ ] Dynamic listeners use
$$on/$$off - [ ] DOM lookup uses
$$find/$$findAllwhere component-relative traversal matters - [ ] Host state reflection uses
$$cls/$$attrwhere appropriate - [ ] Responsive logic uses
ESLMediaQuery/ESLMediaRuleListwhen it is part of the component model - [ ] No unnecessary reimplementation of existing ESL utilities
Practical rule of thumb
If the code looks like generic DOM code with a thin ESL wrapper on top, it is usually worth asking:
Which ESL primitive should own this behavior instead?
Most of the time the answer is one of:
@attr/@boolAttr/@jsonAttr@listen$$find/$$findAll$$cls/$$attrESLMediaQuery/ESLMediaRuleList- an existing
esl-event-listenertarget adapter