src/lib/form/adapters/antd/antd-select-adapter.ts

Total Symbols
12
Lines of Code
529
Avg Complexity
9.4
Avg Coverage
95.1%

File Relationships

graph LR buildField["buildField"] extractDropdownOptions["extractDropdownOptions"] fill["fill"] selectMultipleOptions["selectMultipleOptions"] selectOption["selectOption"] findMatchingOption["findMatchingOption"] getOwnDropdown["getOwnDropdown"] pickRandomOption["pickRandomOption"] waitForDropdownClose["waitForDropdownClose"] clearSearchInput["clearSearchInput"] buildField -->|calls| extractDropdownOptions fill -->|calls| selectMultipleOptions fill -->|calls| selectOption findMatchingOption -->|calls| getOwnDropdown pickRandomOption -->|calls| getOwnDropdown selectOption -->|calls| findMatchingOption selectOption -->|calls| waitForDropdownClose selectOption -->|calls| clearSearchInput selectOption -->|calls| pickRandomOption selectMultipleOptions -->|calls| getOwnDropdown click buildField "../symbols/455296e8c1fe68f0.html" click extractDropdownOptions "../symbols/591c745dfe181710.html" click fill "../symbols/90d6eb0f98a487a1.html" click selectMultipleOptions "../symbols/e53f83dda6e2588d.html" click selectOption "../symbols/fe5ab67a02962060.html" click findMatchingOption "../symbols/fca1c8c5a316b388.html" click getOwnDropdown "../symbols/2e86cf83ed949de3.html" click pickRandomOption "../symbols/911dad38971ca0fd.html" click waitForDropdownClose "../symbols/c6924900ef0c48e4.html" click clearSearchInput "../symbols/be331800440f77a6.html"

Architecture violations

View all

  • [warning] max-cyclomatic-complexity: 'fill' has cyclomatic complexity 15 (max 10)
  • [warning] max-cyclomatic-complexity: 'selectOption' has cyclomatic complexity 35 (max 10)
  • [warning] max-cyclomatic-complexity: 'findMatchingOption' has cyclomatic complexity 11 (max 10)
  • [warning] max-cyclomatic-complexity: 'selectMultipleOptions' has cyclomatic complexity 17 (max 10)
  • [warning] max-lines: 'fill' has 88 lines (max 80)
  • [warning] max-lines: 'selectOption' has 165 lines (max 80)
  • [warning] max-lines: 'selectMultipleOptions' has 107 lines (max 80)

Symbols by Kind

function 8
method 4

All Symbols

Name Kind Visibility Status Lines Signature
matches method - 67-75 matches(el: HTMLElement): : boolean
buildField method - 77-103 buildField(wrapper: HTMLElement): : FormField
extractValue method - 105-123 extractValue(wrapper: HTMLElement): : string | null
fill method - 125-212 fill(wrapper: HTMLElement, value: string): : Promise<boolean>
extractDropdownOptions function - 217-241 extractDropdownOptions( wrapper: HTMLElement, ): : Array<{ value: string; text: string }> | undefined
selectOption function - 243-407 selectOption( wrapper: HTMLElement, value: string, ): : Promise<boolean>
waitForDropdownClose function - 289-305 waitForDropdownClose(): : Promise<void>
findMatchingOption function - 312-331 findMatchingOption(val: string): : HTMLElement | null
pickRandomOption function - 334-346 pickRandomOption(): : HTMLElement | null
clearSearchInput function - 349-354 clearSearchInput(): : void
selectMultipleOptions function - 422-528 selectMultipleOptions( wrapper: HTMLElement, value: string, listboxId: string | null = null, ): : Promise<boolean>
getOwnDropdown function - 440-452 getOwnDropdown(): : HTMLElement | null

Full Source

/**
 * Ant Design Select Adapter
 *
 * Detects and fills `<Select>`, `<TreeSelect>`, `<Cascader>`, and `<AutoComplete>` components.
 *
 * DOM structure — Single (antd v5 classic):
 *   <div class="ant-select ant-select-single ...">
 *     <div class="ant-select-selector">
 *       <span class="ant-select-selection-search">
 *         <input role="combobox" class="ant-select-selection-search-input" />
 *       </span>
 *       <span class="ant-select-selection-placeholder">Placeholder</span>
 *       <span class="ant-select-selection-item">Selected text</span>
 *     </div>
 *   </div>
 *
 * DOM structure — Single (antd v5 CSS-var / v5.17+):
 *   <div class="ant-select ant-select-single ant-select-css-var ...">
 *     <div class="ant-select-content">
 *       <div class="ant-select-placeholder">Placeholder</div>
 *       <input class="ant-select-input" role="combobox" type="search" />
 *     </div>
 *     <div class="ant-select-suffix">...</div>
 *   </div>
 *
 * DOM structure — Multiple (antd v5):
 *   <div class="ant-select ant-select-multiple ...">
 *     <div class="ant-select-content">
 *       <div class="ant-select-content-item">
 *         <span class="ant-select-selection-item">Tag selecionada</span>
 *       </div>
 *       <div class="ant-select-content-item ant-select-content-item-suffix">
 *         <input class="ant-select-input" role="combobox" type="search" />
 *       </div>
 *     </div>
 *   </div>
 *
 * Filling: Opens dropdown, searches for the value, clicks matching option.
 * Multiple mode: selects 1–3 options randomly (or matches comma-separated values).
 */

import type { FormField } from "@/types";
import type { CustomComponentAdapter } from "../adapter.interface";
import {
  findAntLabel,
  findAntId,
  findAntName,
  isAntRequired,
  simulateClick,
  getAntdSelector,
  getUniqueSelector,
  waitForElement,
} from "./antd-utils";
import { buildSignals } from "../../extractors";
import { createLogger } from "@/lib/logger";

const log = createLogger("AntdSelect");

export const antdSelectAdapter: CustomComponentAdapter = {
  name: "antd-select",
  // Exclude auto-complete: it also has .ant-select but has its own adapter that
  // comes after this one in the registry. Without the exclusion, auto-complete
  // elements would be claimed here before antdAutoCompleteAdapter runs.
  selector:
    ".ant-select:not(.ant-select-auto-complete):not(.ant-select-disabled)",

  matches(el: HTMLElement): boolean {
    // Must have the ant-select class, not disabled, and not an AutoComplete
    // (AutoComplete also has .ant-select — its dedicated adapter handles it).
    return (
      el.classList.contains("ant-select") &&
      !el.classList.contains("ant-select-disabled") &&
      !el.classList.contains("ant-select-auto-complete")
    );
  },

  buildField(wrapper: HTMLElement): FormField {
    // Old antd v5 uses .ant-select-selection-placeholder; new CSS-var structure uses .ant-select-placeholder
    const placeholder = (
      wrapper.querySelector<HTMLElement>(".ant-select-selection-placeholder") ??
      wrapper.querySelector<HTMLElement>(".ant-select-placeholder")
    )?.textContent?.trim();

    const isMultiple = wrapper.classList.contains("ant-select-multiple");
    const options = extractDropdownOptions(wrapper);

    const field: FormField = {
      element: wrapper,
      selector: getAntdSelector(wrapper),
      category: "unknown",
      fieldType: isMultiple ? "multiselect" : "select",
      adapterName: "antd-select",
      label: findAntLabel(wrapper),
      name: findAntName(wrapper),
      id: findAntId(wrapper),
      placeholder,
      required: isAntRequired(wrapper),
      options,
    };

    field.contextSignals = buildSignals(field);
    return field;
  },

  extractValue(wrapper: HTMLElement): string | null {
    // prefer displayed selection(s)
    const items = wrapper.querySelectorAll<HTMLElement>(
      ".ant-select-selection-item, .ant-select-selection-item-content",
    );
    if (items.length > 0) {
      return Array.from(items)
        .map((i) => i.textContent?.trim() ?? "")
        .filter((t) => t)
        .join(",");
    }

    // fall back to combobox input value
    const input = wrapper.querySelector<HTMLInputElement>(
      "input[role='combobox'], .ant-select-selection-search-input, .ant-select-input",
    );
    if (input) return input.value;
    return null;
  },

  async fill(wrapper: HTMLElement, value: string): Promise<boolean> {
    const isMultiple = wrapper.classList.contains("ant-select-multiple");
    const wrapperSelector = getUniqueSelector(wrapper);

    const combobox = wrapper.querySelector<HTMLInputElement>(
      "input[role='combobox'], .ant-select-selection-search-input, .ant-select-input",
    );

    // Old antd v5 classic: has .ant-select-selector wrapping the search input.
    // New antd v5 CSS-var / v5.17+: no .ant-select-selector; the input is a direct
    // child of .ant-select-content and IS the toggle trigger.
    // IMPORTANT: for the new structure, we must NOT fire mousedown on BOTH the
    // input and its parent — two consecutive mousedowns on the same React handler
    // chain cause an open/close toggle leaving the dropdown closed.
    const selectorEl = wrapper.querySelector<HTMLElement>(
      ".ant-select-selector",
    );

    if (!selectorEl && !combobox) {
      log.warn(`Container do select não encontrado em: ${wrapperSelector}`);
      return false;
    }

    if (selectorEl) {
      // Old structure: focus + mousedown on search input, then simulateClick selector.
      if (combobox) {
        combobox.focus();
        combobox.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
      }
      simulateClick(selectorEl);
    } else {
      // New CSS-var structure: dispatch mousedown ONLY on the input.
      // A full simulateClick (mousedown → mouseup → click) would cause React to
      // process the 'click' handler and toggle the dropdown closed immediately
      // after the 'mousedown' handler opened it.
      if (combobox) {
        combobox.focus();
        combobox.dispatchEvent(
          new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
        );
      } else {
        // Fallback: trigger via the content wrapper
        const contentEl = wrapper.querySelector<HTMLElement>(
          ".ant-select-content",
        );
        if (contentEl) {
          contentEl.dispatchEvent(
            new MouseEvent("mousedown", { bubbles: true, cancelable: true }),
          );
        }
      }
    }

    // Wait for the dropdown to render
    const dropdown = await waitForElement(
      ".ant-select-dropdown:not(.ant-select-dropdown-hidden)",
      800,
    );

    if (!dropdown) {
      // Last attempt: pointerdown on the direct trigger (single event, no double-fire)
      const triggerEl = selectorEl ?? combobox ?? wrapper;
      triggerEl.dispatchEvent(
        new PointerEvent("pointerdown", { bubbles: true, cancelable: true }),
      );
      await new Promise((r) => setTimeout(r, 300));

      const retryDropdown = document.querySelector<HTMLElement>(
        ".ant-select-dropdown:not(.ant-select-dropdown-hidden)",
      );
      if (!retryDropdown) {
        log.warn(
          `Dropdown .ant-select-dropdown não apareceu para: ${wrapperSelector}`,
        );
        return false;
      }
    }

    // Extract listboxId here (before passing to helpers) so both single and
    // multiple paths can scope their dropdown queries to THIS wrapper's portal.
    const listboxId = combobox?.getAttribute("aria-controls") ?? null;

    if (isMultiple) {
      return await selectMultipleOptions(wrapper, value, listboxId);
    }

    return await selectOption(wrapper, value);
  },
};

// ── Helpers ───────────────────────────────────────────────────────────────────

function extractDropdownOptions(
  wrapper: HTMLElement,
): Array<{ value: string; text: string }> | undefined {
  // Antd renders options in a portal — try to find them via the dropdown ID
  const listboxId = wrapper
    .querySelector<HTMLElement>("[role='combobox']")
    ?.getAttribute("aria-controls");

  if (listboxId) {
    const listbox = document.getElementById(listboxId);
    if (listbox) {
      const items = listbox.querySelectorAll<HTMLElement>("[role='option']");
      const opts = Array.from(items)
        .map((item) => ({
          value: item.getAttribute("title") ?? item.textContent?.trim() ?? "",
          text: item.textContent?.trim() ?? "",
        }))
        .filter((o) => o.value);

      if (opts.length > 0) return opts;
    }
  }

  return undefined;
}

async function selectOption(
  wrapper: HTMLElement,
  value: string,
): Promise<boolean> {
  const searchInput = wrapper.querySelector<HTMLInputElement>(
    ".ant-select-selection-search-input, .ant-select-input",
  );

  const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype,
    "value",
  )?.set;

  // aria-controls on the combobox input points to the listbox ID managed by
  // this specific <Select> instance. We use it to scope ALL dropdown queries
  // to the correct portal element, preventing cross-contamination when another
  // select's dropdown is still animating closed (race condition).
  const listboxId = searchInput?.getAttribute("aria-controls") ?? null;

  const OPTION_SELECTOR =
    ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option";

  /**
   * Returns the dropdown portal that belongs to THIS select wrapper.
   * Primary: resolves via aria-controls → listbox element → closest dropdown.
   * Fallback: first visible dropdown (legacy / SSR builds that omit aria attrs).
   */
  function getOwnDropdown(): HTMLElement | null {
    if (listboxId) {
      const lb = document.getElementById(listboxId);
      if (lb) {
        const dd = lb.closest<HTMLElement>(".ant-select-dropdown");
        if (dd && !dd.classList.contains("ant-select-dropdown-hidden"))
          return dd;
      }
    }
    // Fallback: first visible dropdown
    return document.querySelector<HTMLElement>(
      ".ant-select-dropdown:not(.ant-select-dropdown-hidden)",
    );
  }

  /**
   * Waits until THIS select's dropdown is fully closed (or max ~600 ms).
   * Prevents subsequent selects from picking options from a lingering portal.
   */
  async function waitForDropdownClose(): Promise<void> {
    const deadline = Date.now() + 600;
    while (Date.now() < deadline) {
      const dd = listboxId
        ? document
            .getElementById(listboxId)
            ?.closest<HTMLElement>(".ant-select-dropdown")
        : null;
      if (
        !dd ||
        dd.classList.contains("ant-select-dropdown-hidden") ||
        !document.contains(dd)
      )
        return;
      await new Promise((r) => setTimeout(r, 50));
    }
  }

  /**
   * Try to find an option matching `val` in THIS wrapper's dropdown.
   * Strategy: exact match first, then word-boundary prefix match.
   * Word-boundary avoids false positives like "TO" matching "Mato Grosso".
   */
  function findMatchingOption(val: string): HTMLElement | null {
    if (!val) return null;
    const norm = val.toLowerCase();
    const prefixRe = new RegExp(
      `\\b${val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
      "i",
    );
    const dd = getOwnDropdown();
    if (!dd) return null;
    const options = dd.querySelectorAll<HTMLElement>(".ant-select-item-option");
    for (const opt of options) {
      const t = opt.getAttribute("title") ?? opt.textContent?.trim() ?? "";
      if (t.toLowerCase() === norm) return opt;
    }
    for (const opt of options) {
      const t = opt.getAttribute("title") ?? opt.textContent?.trim() ?? "";
      if (prefixRe.test(t)) return opt;
    }
    return null;
  }

  /** Pick a random non-disabled option from THIS wrapper's dropdown. */
  function pickRandomOption(): HTMLElement | null {
    const dd = getOwnDropdown();
    if (!dd) return null;
    const options = Array.from(
      dd.querySelectorAll<HTMLElement>(
        ".ant-select-item-option:not(.ant-select-item-option-disabled)",
      ),
    );
    if (options.length > 0) {
      return options[Math.floor(Math.random() * options.length)];
    }
    return null;
  }

  /** Clear the search input so the full option list is restored. */
  function clearSearchInput(): void {
    if (searchInput && !searchInput.readOnly && nativeInputValueSetter) {
      nativeInputValueSetter.call(searchInput, "");
      searchInput.dispatchEvent(new Event("input", { bubbles: true }));
    }
  }

  // Phase 1 — Check options that are already visible right after the dropdown opens
  // (static selects). waitForElement resolves immediately when the element exists,
  // so this adds no delay for already-rendered dropdowns.
  await waitForElement(OPTION_SELECTOR, 800);

  const match1 = findMatchingOption(value);
  if (match1) {
    simulateClick(match1);
    await waitForDropdownClose();
    return true;
  }

  // Phase 2 — For AJAX / searchable selects: type the value to trigger server-side
  // filtering or lazy-loading. Only done when Phase 1 found no match.
  if (searchInput && value && !searchInput.readOnly && nativeInputValueSetter) {
    nativeInputValueSetter.call(searchInput, value);
    searchInput.dispatchEvent(new Event("input", { bubbles: true }));
    searchInput.dispatchEvent(new Event("change", { bubbles: true }));

    await waitForElement(OPTION_SELECTOR, 2000);

    const match2 = findMatchingOption(value);
    if (match2) {
      simulateClick(match2);
      await waitForDropdownClose();
      return true;
    }
  }

  // Phase 3 — No match found. Clear any typed search to restore the full option
  // list, then pick a random valid option so the field always ends up with a
  // legitimate value from the actual select options.
  clearSearchInput();
  await waitForElement(OPTION_SELECTOR, 1500);

  const random = pickRandomOption();
  if (random) {
    const randomText =
      random.getAttribute("title") ?? random.textContent?.trim() ?? "";
    log.debug(
      `Fase 3 — nenhuma opção correspondeu a "${value}"; selecionando aleatório: "${randomText}"`,
    );
    simulateClick(random);
    await waitForDropdownClose();
    return true;
  }

  log.warn(
    `Fase 3 — dropdown aberto mas nenhuma opção encontrada (.ant-select-item-option)`,
  );
  return false;
}

/**
 * Selects multiple options from an open ant-select-multiple dropdown.
 *
 * Strategy:
 * 1. If `value` contains comma-separated strings, try to match each one.
 * 2. Otherwise, pick 1–3 random non-selected options from the dropdown.
 * 3. Close the dropdown by pressing Escape after all selections.
 *
 * `listboxId` — the value of `aria-controls` on the combobox input. Used to
 * scope ALL dropdown queries to THIS wrapper's portal so concurrent fills of
 * multiple <Select multiple> fields don't accidentally click options in each
 * other's dropdowns (race condition).
 */
async function selectMultipleOptions(
  wrapper: HTMLElement,
  value: string,
  listboxId: string | null = null,
): Promise<boolean> {
  // Wait for options to load before attempting to click — handles AJAX-loaded selects.
  await waitForElement(
    ".ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option",
    2000,
  );

  /**
   * Returns the dropdown portal that belongs to THIS wrapper.
   * Primary: resolves via aria-controls → listbox element → closest dropdown.
   * Fallback: first visible dropdown (legacy / SSR builds that omit aria attrs).
   * Scoping here prevents the race condition where a previous select's dropdown
   * is still animating closed when this fill begins.
   */
  function getOwnDropdown(): HTMLElement | null {
    if (listboxId) {
      const lb = document.getElementById(listboxId);
      if (lb) {
        const dd = lb.closest<HTMLElement>(".ant-select-dropdown");
        if (dd && !dd.classList.contains("ant-select-dropdown-hidden"))
          return dd;
      }
    }
    return document.querySelector<HTMLElement>(
      ".ant-select-dropdown:not(.ant-select-dropdown-hidden)",
    );
  }

  const dropdown = getOwnDropdown();

  if (!dropdown) {
    log.warn(
      `selectMultipleOptions — dropdown não encontrado para: ${getUniqueSelector(wrapper)}`,
    );
    return false;
  }

  const allOptions = Array.from(
    dropdown.querySelectorAll<HTMLElement>(
      ".ant-select-item-option:not(.ant-select-item-option-disabled)",
    ),
  );

  let selected = false;

  if (allOptions.length > 0) {
    // Collect desired values from comma-separated input (e.g. "Option A, Option B")
    const desiredValues = value
      ? value
          .split(",")
          .map((v) => v.trim())
          .filter(Boolean)
      : [];

    const optionsToClick: HTMLElement[] = [];

    if (desiredValues.length > 0) {
      // Try to find each desired value
      for (const desired of desiredValues) {
        const match = allOptions.find((opt) => {
          const title =
            opt.getAttribute("title") ?? opt.textContent?.trim() ?? "";
          return (
            title.toLowerCase() === desired.toLowerCase() ||
            title.toLowerCase().includes(desired.toLowerCase())
          );
        });
        if (match) optionsToClick.push(match);
      }
    }

    // If nothing matched (or no value provided), pick 1–3 random options
    if (optionsToClick.length === 0) {
      const count = Math.min(
        Math.floor(Math.random() * 3) + 1,
        allOptions.length,
      );
      const shuffled = [...allOptions].sort(() => Math.random() - 0.5);
      optionsToClick.push(...shuffled.slice(0, count));
    }

    for (const opt of optionsToClick) {
      simulateClick(opt);
      selected = true;
    }
  }

  // Close the dropdown by pressing Escape on the search input
  const searchInput = wrapper.querySelector<HTMLInputElement>(
    ".ant-select-selection-search-input, .ant-select-input",
  );
  if (searchInput) {
    searchInput.dispatchEvent(
      new KeyboardEvent("keydown", {
        key: "Escape",
        code: "Escape",
        bubbles: true,
      }),
    );
  }

  return selected;
}