Menu
Public repository / 4f0b1d374fab4075f2b552b5f250bca5f28afee9
AkurAI-Mail / src/framework/recipient-combobox.js
Latest change 054e5cda0171bd13facff3c16a38f2048a1052cc - OLI-249: compose, settings, admin console and sign-in on the framework; SvelteKit removed by Ólafur Búi Ólafsson
// recipient-combobox.js — an address input with autocomplete (framework island logic, plain ESM).
//
// Suggestions come from src/lib/webmail/recipients.js; ArrowUp/Down move, Enter picks, Escape
// closes, and a chosen address replaces the term being typed and leaves ", " for the next one.
import { effect, signal } from "./vendor/signals.js";
import { h, replace } from "./dom.js";
import { completeRecipient, matchSuggestions } from "../lib/webmail/recipients.js";
let nextId = 0;
/**
* @param {object} options
* @param {string} options.label
* @param {string} [options.placeholder]
* @param {() => string} options.value current field value (a signal read)
* @param {(value: string) => void} options.onChange
* @param {() => import("../lib/webmail/recipients.js").Contact[]} options.suggestions
*/
export function recipientCombobox({ label, placeholder = "", value, onChange, suggestions }) {
const listId = `recipients-${++nextId}`;
const open = signal(false);
const active = signal(-1);
const list = h("ul", { class: "combobox-list", id: listId, role: "listbox" });
const input = /** @type {HTMLInputElement} */ (h("input", { placeholder, "aria-label": label, autocomplete: "off", role: "combobox", "aria-autocomplete": "list", "aria-controls": listId }));
const matches = () => matchSuggestions(input.value, suggestions());
const refresh = () => { open(matches().length > 0); active(-1); };
/** @param {string} email */
const choose = (email) => {
input.value = completeRecipient(input.value, email);
onChange(input.value);
open(false);
active(-1);
input.focus();
};
input.addEventListener("input", () => { onChange(input.value); refresh(); });
input.addEventListener("focus", refresh);
input.addEventListener("blur", () => setTimeout(() => open(false), 120));
input.addEventListener("keydown", (event) => {
const found = matches();
if (!open() || !found.length) return;
if (event.key === "ArrowDown") { event.preventDefault(); active((active() + 1) % found.length); }
else if (event.key === "ArrowUp") { event.preventDefault(); active((active() - 1 + found.length) % found.length); }
else if (event.key === "Enter" && active() >= 0) { event.preventDefault(); choose(found[active()].email); }
else if (event.key === "Escape") { open(false); active(-1); }
});
// The store owns the value; typing writes through onChange, outside changes (reply, reset) land here.
effect(() => { const next = value(); if (input.value !== next) input.value = next; });
effect(() => {
const show = open();
const current = active();
input.setAttribute("aria-expanded", String(show));
list.hidden = !show;
replace(list, show ? matches().map((entry, index) =>
h("li", null, h("button", { type: "button", role: "option", "aria-selected": String(index === current), class: index === current ? "active" : null,
onmousedown: (/** @type {MouseEvent} */ event) => { event.preventDefault(); choose(entry.email); } },
entry.name ? `${entry.name} <${entry.email}>` : entry.email))) : []);
});
return h("div", { class: "combobox" }, input, list);
}