Files
genieacs/ui/autocomplete-compnent.ts
T
Zaid Humaimidi 4a191afdec feat(ui): redesign UI with Tailwind CSS, CodeMirror 6, and new layout
- Replace all hand-written CSS with Tailwind utility classes
- Upgrade CodeMirror 5 to CodeMirror 6 (scoped @codemirror/* packages)
- Redesign layout with responsive sidebar navigation using JSX/Mithril
- Flatten admin routes (e.g. /admin/presets -> /presets)
- Add "remember me" login option with 2-week persistent cookie
- Add delete confirmation dialogs across admin pages
- Upgrade esbuild from 0.21 to 0.27 and update browser targets
- Add Inter and Roboto Mono variable fonts with subset script
2026-03-12 14:55:10 -07:00

179 lines
5.2 KiB
TypeScript

type AutocompleteCallback = (
value: string,
callback: (suggestions: { value: string; tip?: string }[]) => void,
) => void;
export default class Autocomplete {
declare private callback: AutocompleteCallback;
declare private element: HTMLInputElement;
declare private hideTimeout: NodeJS.Timeout;
declare private visible: boolean;
declare private default: string;
declare private selection: number;
declare private container: HTMLElement;
public constructor(callback: AutocompleteCallback) {
this.callback = callback;
this.element = null;
this.hideTimeout = null;
this.visible = false;
this.default = null;
this.selection = null;
this.container = document.createElement("div");
this.container.style.position = "absolute";
this.container.style.display = "block";
this.container.style.opacity = "0";
this.container.className =
"absolute py-1 mt-2 rounded-md shadow-lg bg-white";
}
public attach(el: HTMLInputElement): void {
el.setAttribute("autocomplete", "off");
el.addEventListener("focus", () => {
this.element = el;
this.update();
this.reposition();
});
el.addEventListener("blur", () => {
if (this.element !== el) return;
if (!this.visible) return;
this.hide();
});
el.addEventListener("keydown", (e) => {
if (this.element !== el) return;
if (e.key === "Escape") {
if (this.visible) this.hide();
} else if (e.key === "Enter") {
if (this.default != null) {
el.value = this.default;
e.stopImmediatePropagation();
el.dispatchEvent(new InputEvent("input"));
this.update();
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (this.selection == null) this.selection = 0;
else ++this.selection;
this.update();
} else if (e.key === "ArrowUp") {
e.preventDefault();
--this.selection;
this.update();
}
});
el.addEventListener("input", () => {
if (this.element !== el) return;
this.selection = null;
this.update();
});
}
public reposition(): void {
if (!this.element) return;
const domRect = this.element.getBoundingClientRect();
if (!domRect.width) {
// Element has been removed
if (this.visible) this.hide();
return;
}
this.container.style.left = `${domRect.left + window.scrollX}px`;
this.container.style.width = `${domRect.width}px`;
this.container.style.top = `${domRect.bottom + window.scrollY}px`;
}
private hide(): void {
this.container.style.opacity = "0";
this.visible = false;
this.default = null;
this.selection = null;
clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout(() => {
this.hideTimeout = null;
while (this.container.firstChild)
this.container.removeChild(this.container.firstChild);
document.body.removeChild(this.container);
}, 500);
}
private update(): void {
const el = this.element;
this.callback(el.value, (suggestions) => {
if (this.element !== el) return;
this.default = null;
if (!suggestions.length) {
if (this.visible) this.hide();
return;
}
while (this.container.firstChild)
this.container.removeChild(this.container.firstChild);
if (!this.visible) {
if (!this.hideTimeout) {
document.body.appendChild(this.container);
window.getComputedStyle(this.container).opacity;
} else {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
this.container.style.opacity = "1";
this.visible = true;
}
if (this.selection != null) {
this.selection =
((this.selection % suggestions.length) + suggestions.length) %
suggestions.length;
this.default = suggestions[this.selection].value;
} else {
this.default = suggestions[0].value;
}
let selectedElement;
for (const [idx, suggestion] of suggestions.entries()) {
const e = document.createElement("div");
if (suggestion.tip) e.title = suggestion.tip;
e.className =
"text-stone-700 block px-4 py-2 text-sm hover:bg-stone-100 hover:text-stone-900";
if (idx === this.selection) {
e.classList.add("bg-stone-100", "text-stone-900");
selectedElement = e;
}
const t = document.createTextNode(suggestion.value);
e.appendChild(t);
e.addEventListener("mousedown", (ev) => {
ev.preventDefault();
el.value = suggestion.value;
el.dispatchEvent(new InputEvent("input"));
if (this.element === el) this.update();
});
this.container.appendChild(e);
}
// Ensure selected element is in view
if (selectedElement) {
this.container.scrollTop = Math.min(
this.container.scrollTop,
selectedElement.offsetTop,
);
this.container.scrollTop = Math.max(
this.container.scrollTop,
selectedElement.offsetTop +
selectedElement.scrollHeight -
this.container.clientHeight,
);
}
});
}
}