@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:


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:

  1. If the decorated object itself is an Element
  2. Else if it has a $host property that is an Element

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:


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:

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


Best Practices