Tiny·Engine (Core)

Recipe

Dropdown

A class-based dropdown with model binding, directive value parsing, and support for multiple instances.

Markup

dropdown.html
<!-- DROPDOWN: model binding, @click -->
<section style="margin-top: 2rem;">
  <h2>Dropdown (model="selected")</h2>
  <div custom-dropdown model="selected">
    <button ref="toggle">@selected</button>
    <div custom-dropdown-menu ref="menu">
      <a href="javascript:void(0);" @click="select('Home')">Home</a>
      <a href="javascript:void(0);" @click="select('About')">About</a>
      <a href="javascript:void(0);" @click="select('Contact')">Contact</a>
    </div>
  </div>
  <p>Selected: <strong data-dropdown-display>Home</strong></p>
</section>

Capsule

dropdown.ts
import { Capsule, CapsuleOptions, PropsChangeListener } from "tiny-engine-core";

export interface DropdownOptions extends CapsuleOptions {
  model?: string;
  open?: boolean;
}

export class Dropdown extends Capsule {
  static defaults: DropdownOptions = { open: false };

  declare options: DropdownOptions;
  private selectedValue = "";

  constructor(el: HTMLElement, options: DropdownOptions) {
    super(el, options);

    requestAnimationFrame(() => {
      const toggle = this.resolveToggle();
      const menu = this.resolveMenu();

      if (!toggle || !menu) {
        return;
      }

      this.selectedValue = this.resolveInitialValue(menu);
      this.syncSelectionUI();

      this.on(toggle, "click", (event) => {
        event.preventDefault();
        event.stopPropagation();
        this.props.open = !this.props.open;
      });

      menu.querySelectorAll<HTMLAnchorElement>("a").forEach((item) => {
        this.on(item, "click", (event) => {
          event.preventDefault();
          event.stopPropagation();
          this.select(this.extractValue(item));
        });
      });

      this.on(menu, "click", (event) => event.stopPropagation());
      this.on(document, "click", () => {
        if (this.options.open) {
          this.props.open = false;
        }
      });

      this.handleOpenChange(this.options.open ?? false, false, "open");
    });

    this.onPropChange("open", this.handleOpenChange);
  }

  select(value: string): void {
    this.selectedValue = value;
    this.el.setAttribute("data-selected", value);
    this.syncSelectionUI();
    this.props.open = false;
    this.emit("select", { value, model: this.options.model ?? null });
  }

  private resolveToggle(): HTMLButtonElement | null {
    return (
      (this.refs.toggle as HTMLButtonElement | undefined) ??
      this.el.querySelector<HTMLButtonElement>('[ref="toggle"], [ref^="toggle"]') ??
      this.el.querySelector<HTMLButtonElement>("button")
    );
  }

  private resolveMenu(): HTMLElement | null {
    return (
      (this.refs.menu as HTMLElement | undefined) ??
      this.el.querySelector<HTMLElement>('[ref="menu"], [ref^="menu"]') ??
      this.el.querySelector<HTMLElement>("[custom-dropdown-menu]")
    );
  }

  private resolveDisplay(): HTMLElement | null {
    const section = this.el.closest("section");
    if (section) {
      return (
        section.querySelector<HTMLElement>("[data-dropdown-display]") ??
        section.querySelector<HTMLElement>('[ref="display"], [ref^="display"]') ??
        section.querySelector<HTMLElement>('strong[id="selected-display"]')
      );
    }

    return null;
  }

  private resolveInitialValue(menu: HTMLElement): string {
    const displayValue = this.resolveDisplay()?.textContent?.trim();
    if (displayValue) {
      return displayValue;
    }

    const firstItem = menu.querySelector<HTMLAnchorElement>("a");
    return firstItem ? this.extractValue(firstItem) : "";
  }

  private extractValue(item: HTMLAnchorElement): string {
    const dataValue = item.getAttribute("data-value");
    if (dataValue) {
      return dataValue;
    }

    const directive = item.getAttribute("@click");
    if (directive) {
      const match = directive.match(/select\(['"]([^'"]+)['"]\)/);
      if (match) {
        return match[1];
      }
    }

    return item.textContent?.trim() || "";
  }

  private syncSelectionUI(): void {
    const value = this.selectedValue || "";
    const toggle = this.resolveToggle();
    const display = this.resolveDisplay();

    if (toggle) {
      toggle.textContent = value;
    }

    if (display) {
      display.textContent = value;
    }
  }

  private handleOpenChange: PropsChangeListener = (newValue: unknown) => {
    const open = !!newValue;
    const menu = this.resolveMenu();

    if (menu) {
      menu.style.display = open ? "block" : "none";
    }

    const toggle = this.resolveToggle();
    if (toggle) {
      toggle.setAttribute("aria-expanded", open ? "true" : "false");
    }
  };
}

Demo

Demo mention below: two independent dropdowns bind values to nearby display nodes and emit select with the active model id.