AkurAI Build
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);
}