exadel-inc/esl

View on GitHub
src/modules/esl-base-element/core/esl-base-element.ts

Summary

Maintainability
A
1 hr
Test Coverage
A
97%
import {setAttr} from '../../esl-utils/dom/attr';
import {prop} from '../../esl-utils/decorators';
import {ESLEventUtils} from '../../esl-utils/dom/events';
import {CSSClassUtils} from '../../esl-utils/dom/class';

import type {
  ESLEventListener,
  ESLListenerHandler,
  ESLListenerCriteria,
  ESLListenerDescriptor
} from '../../esl-utils/dom/events';
import type {ESLBaseComponent} from '../../esl-utils/abstract/component';

/** Finalize tag name (`is`) for passed ESLBaseElement-based class */
const finalize = (type: typeof ESLBaseElement, tagName: string): void => {
  Object.defineProperty(type, 'is', {
    get: () => tagName,
    set(value) {
      if (this === type) throw Error(`[ESL]: Cannot override ${type.name}.is property, the class is already registered`);
      Object.defineProperty(this, 'is', {value, writable: true, configurable: true});
    }
  });
};

/**
 * Base class for ESL custom elements
 * Allows defining custom element with the optional custom tag name
 */
export abstract class ESLBaseElement extends HTMLElement implements ESLBaseComponent {
  /** Custom element tag name */
  public static is = '';

  /** Event to indicate component significant state change that may affect other components state */
  @prop('esl:refresh') public REFRESH_EVENT: string;

  protected _connected: boolean = false;

  /** @returns custom element tag name */
  public get baseTagName(): string {
    return (this.constructor as typeof ESLBaseElement).is;
  }

  protected connectedCallback(): void {
    this._connected = true;
    this.classList.add(this.baseTagName);

    ESLEventUtils.subscribe(this);
  }
  protected disconnectedCallback(): void {
    this._connected = false;

    ESLEventUtils.unsubscribe(this);
  }

  /**
   * Callback to handle changing of element attributes.
   * Happens when attribute accessed for writing independently of the actual value change
   */
  protected attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {}

  /** Check that the element is connected and `connectedCallback` has been executed */
  public get connected(): boolean {
    return this._connected;
  }

  /** Subscribes `handler` method marked with `@listen` decorator */
  public $$on(handler: ESLListenerHandler): ESLEventListener[];
  /** Subscribes `handler` function by the passed DOM event descriptor {@link ESLListenerDescriptor} or event name */
  public $$on<EType extends keyof ESLListenerEventMap>(
    event: EType | ESLListenerDescriptor<EType>,
    handler: ESLListenerHandler<ESLListenerEventMap[EType]>
  ): ESLEventListener[];
  public $$on(event: any, handler?: any): ESLEventListener[] {
    return ESLEventUtils.subscribe(this, event, handler);
  }

  /** Unsubscribes event listener */
  public $$off(...condition: ESLListenerCriteria[]): ESLEventListener[] {
    return ESLEventUtils.unsubscribe(this, ...condition);
  }

  /**
   * Gets or sets CSS classes for the current element.
   * @param cls - CSS classes query {@link CSSClassUtils}
   * @param value - boolean to set CSS class(es) state or undefined to skip mutation
   * @returns current classes state or passed state
   */
  public $$cls(cls: string, value?: boolean): boolean {
    if (value === undefined) return CSSClassUtils.has(this, cls);
    CSSClassUtils.toggle(this, cls, value);
    return value;
  }

  /**
   * Gets or sets an attribute for the current element.
   * If the `value` param is undefined then skips mutation.
   * @param name - attribute name
   * @param value - string attribute value, boolean attribute state or `null` to delete attribute
   * @returns the current attribute value or previous value for mutation
   */
  public $$attr(name: string, value?: null | boolean | string): string | null {
    const prevValue = this.getAttribute(name);
    if (value !== undefined) setAttr(this, name, value);
    return prevValue;
  }

  /**
   * Dispatches component custom event.
   * @param eventName - event name
   * @param eventInit - custom event init. See {@link CustomEventInit}
   */
  public $$fire(eventName: string, eventInit?: CustomEventInit): boolean {
    return ESLEventUtils.dispatch(this, eventName, eventInit);
  }

  /**
   * Register component in the {@link customElements} registry
   * @param tagName - custom tag name to register custom element
   */
  public static register(this: typeof ESLBaseElement, tagName?: string): void {
    tagName = tagName || this.is;
    if (!tagName) throw new DOMException('[ESL]: Incorrect tag name', 'NotSupportedError');
    const constructor: any = customElements.get(tagName);
    if (constructor && (constructor !== this || constructor.is !== tagName)) {
      throw new DOMException('[ESL]: Element tag already occupied or inconsistent', 'NotSupportedError');
    }
    if (constructor) return;
    finalize(this, tagName);
    customElements.define(tagName, this as any as CustomElementConstructor);
  }

  /** Shortcut for `customElements.whenDefined(currentCustomElement)` */
  public static get registered(): Promise<CustomElementConstructor> {
    return customElements.whenDefined(this.is);
  }

  /** Creates an instance of the current custom element */
  public static create<T extends typeof ESLBaseElement>(this: T): InstanceType<T>;
  /** General signature of {@link create} to allow simplified overrides of the method */
  public static create(this: typeof ESLBaseElement): ESLBaseElement;
  public static create<T extends typeof ESLBaseElement>(this: T): InstanceType<T> {
    return document.createElement(this.is) as InstanceType<T>;
  }
}