@attr Decorator
Maps a class property to an HTML attribute with optional parsing, serialization, inheritance and a JS‑side default fallback.
Why
HTML attributes are always strings (or presence/absence). Component logic often needs typed values (boolean, number, object, list) plus sensible defaults and inheritance across nested elements. Manual getAttribute
/ setAttribute
boilerplate is repetitive and error‑prone. @attr
centralizes:
- Name resolution (auto kebab‑case + optional
data-
prefix) - One-way or two-way mapping (readonly vs read/write)
- Typed parsing / serialization
- Optional inheritance up the DOM tree
- Default (or provider) when attribute is absent (without mutating DOM)
- Works with host wrapper objects exposing
$host
(e.g.ESLMixinElement
), not only directHTMLElement
subclasses
Quick Start
import {attr} from '@exadel/esl/modules/esl-utils/decorators';
class Modal extends HTMLElement {
// Simple string (default parser: parseString) – empty string when attribute missing
@attr() title!: string;
// Tri-state boolean (default true, explicit false via attribute value)
@attr({defaultValue: true, parser: parseBoolean, serializer: toBooleanAttribute}) closable!: boolean;
// Number with fallback when attribute missing / unparsable
@attr({parser: (v) => v == null ? 0 : parseFloat(v)}) delay!: number;
}
Host Resolution (Non-HTMLElement Support)
@attr
(and other attribute decorators) internally resolve the target element using a utility that first checks:
- If the decorated object itself is an
Element
- Else if it has a
$host
property that is anElement
This enables usage in composition / mixin patterns where logic lives in a helper class referencing a real DOM host via $host
.
class SizeBehaviour { // not an HTMLElement
constructor(public $host: HTMLElement) {}
@attr({parser: parseNumber, defaultValue: 0}) width!: number; // reads/writes $host attribute
}
const el = document.createElement('div');
const behavior = new SizeBehaviour(el);
behavior.width = 250; // sets attribute on the underlying element
Notes:
- No special configuration required;
$host
is picked up automatically. - If
$host
is absent or null, reads yield default logic (null → defaultValue), writes are ignored.
API
attr<T = string>(config?: AttrConfig<T>): PropertyDecorator;
Where:
interface AttrConfig<T> {
name?: string; // custom attribute name (kebab-cased by default)
readonly?: boolean; // getter only (no attribute updates)
inherit?: boolean | string; // inherit from nearest ancestor attribute (same or alternate name)
dataAttr?: boolean; // prefix with data-
defaultValue?: T | ((this: any, that: any) => T); // JS fallback when attribute absent
parser?: (raw: string | null) => T; // raw attr -> typed
serializer?: (value: T) => string | boolean | null; // typed -> attr representation
}
Behavior Summary
Operation | What Happens |
---|---|
Read | Find local attribute; if absent and inherit enabled search ancestor; if still absent and defaultValue provided resolve it (call provider each access); pass resulting string/null to parser (default: parseString ). |
Write | If not readonly , pass value through serializer (or identity) and call setAttr . Return rules below. |
Remove | Setting serializer result to null , false , or undefined removes attribute. |
Boolean serializer | Returning true sets an empty attribute (attr="" ). |
Provider default | Not written to DOM; purely JS fallback. |
Inheritance (inherit: true ) | Uses same attribute name; if local missing, climbs ancestors. |
Inheritance (string) | Uses local name first, then alternate provided name on ancestors. |
Parser / Serializer Patterns
Below are common recipes and how they differ from dedicated decorators.
1. Tri‑State Boolean (Contrast with @boolAttr
)
@attr({defaultValue: true, parser: parseBoolean, serializer: toBooleanAttribute}) enabled!: boolean;
States:
- No attribute: property =
true
(fromdefaultValue
) enabled="false"
: property =false
- Other attribute forms (
""
,"true"
, absence of falsey markers): property =true
Difference vs@boolAttr
:@boolAttr
is binary (attribute present -> true, absent -> false). Tri‑state pattern enables a semantic default without setting DOM attribute.
2. Boolean Presence Only (@boolAttr
Equivalent Light)
@attr({parser: (v) => v !== null, serializer: (v) => !!v}) active!: boolean;
Mirrors @boolAttr
but less explicit—prefer @boolAttr
unless combining with inheritance or advanced naming.
3. Numeric Attribute
@attr({parser: (v) => v == null ? 0 : parseFloat(v)}) timeout!: number; // NaN guarded
Or using existing helper:
@attr({parser: (v) => v == null ? 0 : parseNumber(v, 0)}) timeout!: number; // parseNumber returns fallback for NaN
Choose parseFloat
for raw NaN signaling or parseNumber
for controlled fallback.
4. JSON-like Data (Equivalent to @jsonAttr
)
Current preferred approach for object mapping is @jsonAttr
, but you can emulate:
@attr({
parser: (v) => v ? JSON.parse(v) : {},
serializer: (val) => (val && Object.keys(val).length) ? JSON.stringify(val) : false,
defaultValue: () => ({})
}) config!: Record<string, any>;
Upcoming (ESL v6.0.0
) helper parseObject
will simplify the parser:
// Future (planned)
@attr({parser: parseObject, serializer: (o) => JSON.stringify(o)}) data!: SomeShape;
For now, use @jsonAttr
when you want standard JSON + default object semantics.
5. Token List (Custom Rule)
@attr({
parser: (v) => (v ?? '').split(/\s+/).filter(Boolean),
serializer: (arr) => arr.length ? arr.join(' ') : null,
defaultValue: () => []
}) classes!: string[];
6. Inherited Override
@attr({inherit: true, parser: parseBoolean, defaultValue: false}) muted!: boolean;
If an ancestor defines muted
attribute, its value cascades; local attribute overrides; absence yields false
(default provider).
Serialization Rules (Detailed)
Serializer Return | DOM Result |
---|---|
null , undefined , false | Attribute removed |
true | Empty attribute written (attr="" ) |
'' (empty string) | Attribute set to empty string |
'value' | Attribute set to provided string |
Default Value Clarification
defaultValue
(or provider) is only applied when no (local + inherited) attribute is found. It does NOT create or mutate the HTML attribute. A provider executes each time the getter runs while the attribute remains absent (no memoization).
Inheritance Examples
// Same-name cascade
@attr({inherit: true}) theme!: string; // climbs for `theme`
// Alternate ancestor name
@attr({inherit: 'global-theme'}) theme!: string; // local `theme` first, else ancestor `global-theme`
Readonly Mapping
@attr({readonly: true}) mode!: string; // Reflects attribute but writes are ignored
Useful when DOM updates come from outside (e.g., server-rendered or managed by another system) and internal code should not mutate the attribute.
Comparing Decorators
Decorator | Type Focus | Default Handling | Boolean Semantics | Object Handling | Extras |
---|---|---|---|---|---|
@attr | Generic (string → any via parser) | defaultValue /provider (JS only) | Configurable (tri-state possible) | Custom parser needed | Inheritance, custom serializer |
@boolAttr | Presence toggle | Absent = false | Binary (presence) | N/A | Simpler, optimized |
@jsonAttr | Object (JSON) | Default object literal | N/A | Built-in JSON parse/stringify | Simpler config |
Error / Edge Considerations
- Malformed JSON in custom parser: wrap in try/catch to prevent uncaught errors.
- Provider default returning complex objects: you may want to clone to prevent shared mutation.
- Inheritance cycles are naturally bounded by DOM tree; no special guard required.
Best Practices
- Always supply a deterministic parser; avoid throwing—return a safe fallback instead.
- For expensive default providers, cache manually on first use if attribute remains unset.
- Avoid heavy parsing on every access; consider caching derived values in another property if frequently read.