SearchableSelect class presentation exported
Last updated: 2026-03-04T23:21:38.428Z
Metrics
LOC: 240
Complexity: 48
Params: 0
Signature
class SearchableSelect
Architecture violations
- [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)
| Source | Type |
|---|---|
| fieldTypeLabel | uses |
| upgradeRowSearchableSelects | instantiates |
| generateId | uses |
| initSearchableSelects | instantiates |
| handleRuleButtonClick | uses |
| showRulePopup | instantiates |
| buildContainer | uses |
| mount | instantiates |