src/lib/ui/searchable-select.ts

Total Symbols
23
Lines of Code
305
Avg Complexity
5.3
Symbol Types
5

File Relationships

graph LR flattenEntries["flattenEntries"] isGroup["isGroup"] constructor["constructor"] flattenEntries -->|calls| isGroup constructor -->|calls| flattenEntries click flattenEntries "../symbols/da78fff9504489c0.html" click isGroup "../symbols/78ccd159e2262e09.html" click constructor "../symbols/84b2188bdf2643c4.html"

Architecture violations

View all

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

Symbols by Kind

method 15
interface 3
type 2
function 2
class 1

All Symbols

Name Kind Visibility Status Lines Signature
SelectOption interface exported- 17-20 interface SelectOption
SelectOptionGroup interface exported- 22-25 interface SelectOptionGroup
SelectEntry type exported- 27-27 type SelectEntry
isGroup function - 29-31 isGroup(entry: SelectEntry): : entry is SelectOptionGroup
SearchableSelectOptions interface exported- 33-44 interface SearchableSelectOptions
ChangeListener type - 46-46 type ChangeListener
flattenEntries function - 49-63 flattenEntries( entries: SelectEntry[], ): : Array<SelectOption & { groupLabel?: string }>
SearchableSelect class exported- 65-304 class SearchableSelect
constructor method - 81-87 constructor(private readonly opts: SearchableSelectOptions)
on method - 90-97 on(event: "change", cb: ChangeListener): : () => void
getValue method - 99-101 getValue(): : string
setValue method - 103-109 setValue(value: string): : void
mount method - 112-151 mount(container: HTMLElement): : this
destroy method - 153-161 destroy(): : void
bindEvents method private - 165-181 bindEvents(): : void
open method private - 183-191 open(): : void
close method private - 193-201 close(restoreLabel = true): : void
onInputChange method private - 203-213 onInputChange(): : void
onKeyDown method private - 215-241 onKeyDown(e: KeyboardEvent): : void
highlightItem method private - 243-250 highlightItem(items: HTMLElement[], index: number): : void
selectByValue method private - 252-261 selectByValue(value: string): : void
renderOptions method private - 263-296 renderOptions( filtered: Array<SelectOption & { groupLabel?: string }>, ): : void
handleOutsideClick method private - 298-303 handleOutsideClick(e: MouseEvent): : void

Full Source

/**
 * SearchableSelect — vanilla DOM component that replaces a `<select>` with
 * a searchable dropdown. Framework-agnostic; works in content scripts, popup
 * and options page.
 *
 * API:
 *   const ss = new SearchableSelect(options);
 *   ss.mount(container);      // renders into a given HTMLElement
 *   ss.getValue()             // → current value
 *   ss.setValue(v)            // programmatically set value
 *   ss.destroy()              // remove DOM + listeners
 *   ss.on("change", cb)       // subscribe to selection changes
 */

import { escHtml } from "./html-utils";

export interface SelectOption {
  value: string;
  label: string;
}

export interface SelectOptionGroup {
  groupLabel: string;
  options: SelectOption[];
}

export type SelectEntry = SelectOption | SelectOptionGroup;

function isGroup(entry: SelectEntry): entry is SelectOptionGroup {
  return "groupLabel" in entry;
}

export interface SearchableSelectOptions {
  /** Options or grouped list of options. */
  entries: SelectEntry[];
  /** Initial selected value. */
  value?: string;
  /** Placeholder for the search input. */
  placeholder?: string;
  /** CSS class(es) to add to the root wrapper. */
  className?: string;
  /** Whether to disable the component. */
  disabled?: boolean;
}

type ChangeListener = (value: string, label: string) => void;

/** Builds a flat searchable list from entries */
function flattenEntries(
  entries: SelectEntry[],
): Array<SelectOption & { groupLabel?: string }> {
  const result: Array<SelectOption & { groupLabel?: string }> = [];
  for (const entry of entries) {
    if (isGroup(entry)) {
      for (const o of entry.options) {
        result.push({ ...o, groupLabel: entry.groupLabel });
      }
    } else {
      result.push(entry);
    }
  }
  return result;
}

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();
    }
  }
}