@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
597 lines (445 loc) • 19.5 kB
Markdown
# 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 **`/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 `/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:
```ts
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 `core` entries.
- Root import from `@exadel/esl` is 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`
- ``
- attribute decorators like ``, ``, ``
The main difference is **where those APIs act**:
- in `ESLBaseElement` they target the element itself
- in `ESLMixinElement` they target the mixin host (`$host`)
---
## `ESLBaseElement` and `ESLMixinElement`
### `ESLBaseElement`
Use when you need a **new HTML tag** with its own DOM lifecycle.
```ts
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';
({defaultValue: ''}) public title: string;
() public active: boolean;
({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();
}
('click')
protected _onClick(e: MouseEvent): void {
// ...
}
}
MyElement.register();
```
### `ESLMixinElement`
Use when you need to **attach behavior to an existing element** via an attribute.
```ts
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'];
({defaultValue: ''}) public title: string;
() public active: boolean;
({defaultValue: {}}) public config: Record<string, unknown>;
protected override connectedCallback(): void {
super.connectedCallback();
// init logic on this.$host
}
protected override disconnectedCallback(): void {
super.disconnectedCallback();
}
('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 is` **before** calling `register()`.
- `ESLBaseElement.register()` optionally accepts a tag name, but the normal consumer path is defining `static is` in the class.
- Custom element tag names and mixin `is` attributes must contain a dash to comply with custom element naming rules.
- Do **not** mutate `is` after registration.
### Lifecycle rules
- Always call `super.connectedCallback()`.
- Always call `super.disconnectedCallback()`.
- `` is 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.
- `attributeChangedCallback` reacts only to observed attributes.
- For `ESLBaseElement`, that means attributes listed in `static observedAttributes`.
- For `ESLMixinElement`, that means attributes listed in `static observedAttributes`, plus the primary mixin `is` attribute, which is always observed.
- When triggered, `attributeChangedCallback` may still run on **every write**, not only on actual value change.
- Guard expensive reactions when needed:
```ts
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 `` |
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
```ts
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)')` or `this.$$findAll('::find(button)')`.
Do **not** treat `$$find` / `$$findAll` as a ban on native DOM APIs:
- `this.querySelector(...)` / `this.querySelectorAll(...)` are still completely valid inside `ESLBaseElement` when a normal element-scoped CSS query is enough.
- Prefer `$$find` / `$$findAll` when 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.
### ``
Generic property-to-attribute mapping.
Use it for:
- strings
- numbers
- tri-state booleans
- inherited values
- custom parsing/serialization
```ts
import {attr} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
({defaultValue: ''}) public title: string;
({
defaultValue: true,
parser: (v) => v !== 'false',
serializer: (v) => v ? '' : null,
})
public closable: boolean;
}
```
Capabilities:
- custom attribute name
- `data-*` attributes
- `readonly`
- `defaultValue`
- custom parser/serializer
- inheritance from ancestors
### ``
Boolean presence attribute.
```ts
import {boolAttr} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
() public disabled: boolean;
}
```
Semantics:
- attribute present → `true`
- attribute absent → `false`
Use ``, not ``, when you need a default-enabled or tri-state boolean.
### ``
Object mapping decorator.
```ts
import {jsonAttr} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
({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:
```html
<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.
### ``
Prototype-level shared property or provider-backed property.
Use it to:
- define shared constants
- define provider-backed values
- override inherited `` / `` / `` mappings in subclasses
```ts
import {attr, prop} from '@exadel/esl/modules/esl-utils/decorators';
class BasePanel extends ESLBaseElement {
({defaultValue: 'info'}) public kind: string;
}
class WarningPanel extends BasePanel {
('warning', {readonly: true}) public override kind: string;
}
```
### Property providers
A provider is a function that receives the host as both `this` and argument.
```ts
(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:
- `` fields such as dynamic `event`, `target`, `selector`, or `condition`
- `({defaultValue: (...) => ...})`
- `((that) => ...)`
This is also the main way to pass the current instance into decorator configuration.
---
## Event model: `` vs `$$on` / `$$off`
### ``
Use `` for **class-level declarative event listeners**.
```ts
import {listen} from '@exadel/esl/modules/esl-utils/decorators';
class Example extends ESLBaseElement {
('click')
protected _onClick(e: MouseEvent): void {}
({event: 'keydown', target: 'window'})
protected _onKeydown(e: KeyboardEvent): void {}
({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 `` by default for stable listeners that belong to the component class.
### `$$on` / `$$off`
Use them for **manual or dynamic subscription control**.
```ts
class Example extends ESLBaseElement {
({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
- `` = 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 `` or manual subscriptions:
- `ESLDecoratedEventTarget.for(target, decorator, ...args)`
- wraps an `EventTarget` with debounce/throttle-like behavior
- `ESLResizeObserverTarget.for(el)`
- gives `resize` events from `ResizeObserver`
- `ESLIntersectionTarget.for(el, settings?)`
- gives `intersection` events from `IntersectionObserver`
- `ESLSwipeGestureTarget.for(el, settings?)`
- gives `swipe` events
- `ESLWheelTarget.for(el, settings?)`
- gives `longwheel` events
Example:
```ts
import {listen} from '@exadel/esl/modules/esl-utils/decorators';
import {ESLMediaQuery} from '@exadel/esl/modules/esl-media-query/core';
class Example extends ESLBaseElement {
({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
```ts
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:
```ts
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:
```ts
import {ESLMediaQuery} from '/esl/modules/esl-media-query/core';
const mq = ESLMediaQuery.for(' and ');
if (mq.matches) {
// desktop medium-and-up behavior
}
```
And with listeners:
```ts
class Example extends ESLBaseElement {
({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:
```ts
ESLMediaRuleList.parse('default | => compact | @+md => full');
ESLMediaRuleList.parse(' => {gap: 8} | @+md => {gap: 16}', ESLMediaRuleList.OBJECT_PARSER);
// tuple format: values and queries are passed separately
ESLMediaRuleList.parse('1|2|3', '||');
```
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 | => 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:
- `` — defer execution until the DOM is ready (`DOMContentLoaded`) and the next task
- `` — lazy per-instance method binding
- `` — wrap methods with debounce/throttle-like decorators
- `` — cache getter/method results
- `` — catch sync errors and report through `$$error`
---
## Common mistakes to avoid
- Importing from repository internals instead of public `/esl/modules/.../core` paths.
- Treating `ESLBaseElement` and `ESLMixinElement` as 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 `addEventListener` for static class-owned listeners instead of ``.
- Using `` when a tri-state or inherited value actually requires ``.
- Assuming `` accepts 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:
1. Choose the host model first: tag or mixin.
2. Import from public `core` entrypoints.
3. Use decorators for attribute/state mapping.
4. Use `` for stable listeners.
5. Use `$$on` / `$$off` for dynamic listeners.
6. Use `$$find` / `$$findAll` for component-relative lookup.
7. Use `$$cls` / `$$attr` for host state reflection.
8. Reach for `ESLMediaQuery` / `ESLMediaRuleList` when responsiveness is part of the API.