src/lib/ui/searchable-select.ts
File Relationships
Architecture violations
- [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();
}
}
}