SearchableSelect class presentation exported

Last updated: 2026-03-04T23:21:38.428Z

Metrics

LOC: 240 Complexity: 48 Params: 0

Signature

class SearchableSelect

Architecture violations

View all

  • [warning] max-cyclomatic-complexity: 'SearchableSelect' has cyclomatic complexity 48 (max 10)
  • [warning] max-lines: 'SearchableSelect' has 240 lines (max 80)

Source Code

export class SearchableSelect {
  private root: HTMLElement | null = null;
  private input: HTMLInputElement | null = null;
  private dropdown: HTMLElement | null = null;
  private hiddenInput: HTMLInputElement | null = null;

  private _value: string;
  private _label: string = "";
  private _open = false;
  private _highlighted: number = -1;

  private readonly flat: Array<SelectOption & { groupLabel?: string }>;
  private _listeners: ChangeListener[] = [];

  private boundHandleOutsideClick: (e: MouseEvent) => void;

  constructor(private readonly opts: SearchableSelectOptions) {
    this.flat = flattenEntries(opts.entries);
    this._value = opts.value ?? "";
    const found = this.flat.find((o) => o.value === this._value);
    this._label = found?.label ?? "";
    this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
  }

  /** Subscribe to value changes. Returns unsubscribe function. */
  on(event: "change", cb: ChangeListener): () => void {
    if (event === "change") {
      this._listeners.push(cb);
    }
    return () => {
      this._listeners = this._listeners.filter((l) => l !== cb);
    };
  }

  getValue(): string {
    return this._value;
  }

  setValue(value: string): void {
    const found = this.flat.find((o) => o.value === value);
    this._value = value;
    this._label = found?.label ?? value;
    if (this.input) this.input.value = this._label;
    if (this.hiddenInput) this.hiddenInput.value = this._value;
  }

  /** Mount the component inside `container`. */
  mount(container: HTMLElement): this {
    const wrapper = document.createElement("div");
    wrapper.className = [
      "fa-ss",
      this.opts.className ?? "",
      this.opts.disabled ? "fa-ss--disabled" : "",
    ]
      .filter(Boolean)
      .join(" ");

    wrapper.innerHTML = `
      <div class="fa-ss__input-wrap">
        <input
          type="text"
          class="fa-ss__input"
          autocomplete="off"
          spellcheck="false"
          placeholder="${escHtml(this.opts.placeholder ?? "Pesquisar…")}"
          value="${escHtml(this._label)}"
          ${this.opts.disabled ? "disabled" : ""}
          aria-haspopup="listbox"
          aria-expanded="false"
          role="combobox"
        />
        <span class="fa-ss__arrow" aria-hidden="true">▾</span>
      </div>
      <div class="fa-ss__dropdown" role="listbox" hidden></div>
      <input type="hidden" class="fa-ss__value" value="${escHtml(this._value)}" />
    `;

    this.root = wrapper;
    this.input = wrapper.querySelector<HTMLInputElement>(".fa-ss__input")!;
    this.dropdown = wrapper.querySelector<HTMLElement>(".fa-ss__dropdown")!;
    this.hiddenInput =
      wrapper.querySelector<HTMLInputElement>(".fa-ss__value")!;

    this.bindEvents();
    container.appendChild(wrapper);
    return this;
  }

  destroy(): void {
    document.removeEventListener("mousedown", this.boundHandleOutsideClick);
    this.root?.remove();
    this.root = null;
    this.input = null;
    this.dropdown = null;
    this.hiddenInput = null;
    this._listeners = [];
  }

  // ─── Private ───────────────────────────────────────────────────────────────

  private bindEvents(): void {
    if (!this.input || !this.dropdown) return;

    this.input.addEventListener("focus", () => this.open());
    this.input.addEventListener("click", () => this.open());
    this.input.addEventListener("input", () => this.onInputChange());
    this.input.addEventListener("keydown", (e) => this.onKeyDown(e));

    this.dropdown.addEventListener("mousedown", (e) => {
      const li = (e.target as HTMLElement).closest<HTMLElement>(".fa-ss__opt");
      if (!li || li.dataset.disabled) return;
      e.preventDefault();
      this.selectByValue(li.dataset.value!);
    });

    document.addEventListener("mousedown", this.boundHandleOutsideClick);
  }

  private open(): void {
    if (this._open || this.opts.disabled) return;
    this._open = true;
    this._highlighted = -1;
    this.input!.value = "";
    this.input!.setAttribute("aria-expanded", "true");
    this.renderOptions(this.flat);
    this.dropdown!.removeAttribute("hidden");
  }

  private close(restoreLabel = true): void {
    if (!this._open) return;
    this._open = false;
    this.input!.setAttribute("aria-expanded", "false");
    this.dropdown!.setAttribute("hidden", "");
    if (restoreLabel && this.input) {
      this.input.value = this._label;
    }
  }

  private onInputChange(): void {
    if (!this._open) this.open();
    const query = this.input!.value.toLowerCase();
    const filtered = this.flat.filter(
      (o) =>
        o.label.toLowerCase().includes(query) ||
        o.value.toLowerCase().includes(query),
    );
    this._highlighted = -1;
    this.renderOptions(filtered);
  }

  private onKeyDown(e: KeyboardEvent): void {
    const items = this.dropdown
      ? Array.from(this.dropdown.querySelectorAll<HTMLElement>(".fa-ss__opt"))
      : [];

    if (e.key === "ArrowDown") {
      e.preventDefault();
      if (!this._open) {
        this.open();
        return;
      }
      this._highlighted = Math.min(this._highlighted + 1, items.length - 1);
      this.highlightItem(items, this._highlighted);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      this._highlighted = Math.max(this._highlighted - 1, 0);
      this.highlightItem(items, this._highlighted);
    } else if (e.key === "Enter") {
      e.preventDefault();
      const item = items[this._highlighted];
      if (item) {
        this.selectByValue(item.dataset.value!);
      }
    } else if (e.key === "Escape" || e.key === "Tab") {
      this.close();
    }
  }

  private highlightItem(items: HTMLElement[], index: number): void {
    for (const item of items) item.classList.remove("fa-ss__opt--highlighted");
    const target = items[index];
    if (target) {
      target.classList.add("fa-ss__opt--highlighted");
      target.scrollIntoView({ block: "nearest" });
    }
  }

  private selectByValue(value: string): void {
    const found = this.flat.find((o) => o.value === value);
    if (!found) return;
    this._value = found.value;
    this._label = found.label;
    if (this.hiddenInput) this.hiddenInput.value = this._value;
    this.close(false);
    if (this.input) this.input.value = this._label;
    for (const cb of this._listeners) cb(this._value, this._label);
  }

  private renderOptions(
    filtered: Array<SelectOption & { groupLabel?: string }>,
  ): void {
    if (!this.dropdown) return;

    if (filtered.length === 0) {
      this.dropdown.innerHTML = `<li class="fa-ss__empty" role="option">Nenhum resultado</li>`;
      return;
    }

    const html: string[] = [];
    let lastGroup: string | undefined = undefined;

    for (const opt of filtered) {
      if (opt.groupLabel !== lastGroup) {
        lastGroup = opt.groupLabel;
        if (opt.groupLabel) {
          html.push(
            `<li class="fa-ss__group" role="presentation">${escHtml(opt.groupLabel)}</li>`,
          );
        }
      }
      const selected = opt.value === this._value;
      html.push(
        `<li class="fa-ss__opt${selected ? " fa-ss__opt--selected" : ""}"
             role="option"
             aria-selected="${selected}"
             data-value="${escHtml(opt.value)}"
         >${escHtml(opt.label)}</li>`,
      );
    }

    this.dropdown.innerHTML = html.join("");
  }

  private handleOutsideClick(e: MouseEvent): void {
    if (!this._open) return;
    if (this.root && !this.root.contains(e.target as Node)) {
      this.close();
    }
  }
}

Members

Name Kind Visibility Status Signature
constructor method - constructor(private readonly opts: SearchableSelectOptions)
on method - on(event: "change", cb: ChangeListener): : () => void
getValue method - getValue(): : string
setValue method - setValue(value: string): : void
mount method - mount(container: HTMLElement): : this
destroy method - destroy(): : void
bindEvents method private - bindEvents(): : void
open method private - open(): : void
close method private - close(restoreLabel = true): : void
onInputChange method private - onInputChange(): : void
onKeyDown method private - onKeyDown(e: KeyboardEvent): : void
highlightItem method private - highlightItem(items: HTMLElement[], index: number): : void
selectByValue method private - selectByValue(value: string): : void
renderOptions method private - renderOptions( filtered: Array<SelectOption & { groupLabel?: string }>, ): : void
handleOutsideClick method private - handleOutsideClick(e: MouseEvent): : void

No outgoing dependencies.

Impact (Incoming)

graph LR SearchableSelect["SearchableSelect"] fieldTypeLabel["fieldTypeLabel"] upgradeRowSearchableSelects["upgradeRowSearchableSelects"] generateId["generateId"] initSearchableSelects["initSearchableSelects"] handleRuleButtonClick["handleRuleButtonClick"] showRulePopup["showRulePopup"] buildContainer["buildContainer"] mount["mount"] fieldTypeLabel -->|uses| SearchableSelect upgradeRowSearchableSelects -.->|instantiates| SearchableSelect generateId -->|uses| SearchableSelect initSearchableSelects -.->|instantiates| SearchableSelect handleRuleButtonClick -->|uses| SearchableSelect showRulePopup -.->|instantiates| SearchableSelect buildContainer -->|uses| SearchableSelect mount -.->|instantiates| SearchableSelect style SearchableSelect fill:#dbeafe,stroke:#2563eb,stroke-width:2px click SearchableSelect "194c113b97ce486d.html" click fieldTypeLabel "861a0ec11bc0b27b.html" click upgradeRowSearchableSelects "90e9f01206f5f2c7.html" click generateId "fe0b977e1a4503ec.html" click initSearchableSelects "4565dc3705ac017b.html" click handleRuleButtonClick "302ac6e6209ebb8e.html" click showRulePopup "9a11db3b71d6ccf5.html" click buildContainer "a0615493bb53efa4.html" click mount "42a7c100316854cd.html"
SourceType
fieldTypeLabel uses
upgradeRowSearchableSelects instantiates
generateId uses
initSearchableSelects instantiates
handleRuleButtonClick uses
showRulePopup instantiates
buildContainer uses
mount instantiates