treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
This commit is contained in:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -11,15 +11,16 @@
|
|||
// initial /api/state fetch (compose autocomplete needs the live
|
||||
// container list).
|
||||
|
||||
import { create as termCreate } from '@hive/shared/terminal.js';
|
||||
import { create as termCreate } from "@hive/shared/terminal.js";
|
||||
import {
|
||||
$,
|
||||
NOTIF,
|
||||
appendLinkified,
|
||||
openStream, initServerWarnings,
|
||||
} from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { epochSec } from './util.js';
|
||||
openStream,
|
||||
initServerWarnings,
|
||||
} from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { epochSec } from "./util.js";
|
||||
|
||||
(() => {
|
||||
NOTIF.bind();
|
||||
|
|
@ -32,11 +33,16 @@ import { epochSec } from './util.js';
|
|||
// by the same `container_state_changed` / `container_removed` events
|
||||
// the dashboard would handle.
|
||||
const flowContainers = new Map();
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
populateAgentFilter();
|
||||
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
populateAgentFilter();
|
||||
})
|
||||
.catch(() => {
|
||||
/* graceful: compose just shows `*` and nothing else */
|
||||
});
|
||||
|
||||
// ─── agent filter ───────────────────────────────────────────────
|
||||
// A select in the FL0W header narrows the timeline to messages involving
|
||||
|
|
@ -46,40 +52,40 @@ import { epochSec } from './util.js';
|
|||
// filter re-scans existing rows. Selection persists in localStorage so a
|
||||
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
|
||||
// so it works regardless of the message-flow IIFE's local scope.
|
||||
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
|
||||
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
|
||||
function rowMatchesFilter(from, to) {
|
||||
return !agentFilter || from === agentFilter || to === agentFilter;
|
||||
}
|
||||
function applyAgentFilter() {
|
||||
const flow = $('msgflow');
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
for (const row of flow.children) {
|
||||
const { from, to } = row.dataset;
|
||||
if (from === undefined && to === undefined) continue; // non-message row
|
||||
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
|
||||
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
|
||||
}
|
||||
}
|
||||
function populateAgentFilter() {
|
||||
const sel = $('flow-agent-filter');
|
||||
const sel = $("flow-agent-filter");
|
||||
if (!sel) return;
|
||||
const names = [...flowContainers.keys()].sort();
|
||||
sel.replaceChildren();
|
||||
sel.append(el('option', { value: '' }, 'all agents'));
|
||||
for (const n of names) sel.append(el('option', { value: n }, n));
|
||||
sel.append(el("option", { value: "" }, "all agents"));
|
||||
for (const n of names) sel.append(el("option", { value: n }, n));
|
||||
// Preserve a saved selection even if that agent isn't in the live
|
||||
// container list (yet / anymore) so the filter doesn't silently reset.
|
||||
if (agentFilter && !names.includes(agentFilter)) {
|
||||
sel.append(el('option', { value: agentFilter }, agentFilter));
|
||||
sel.append(el("option", { value: agentFilter }, agentFilter));
|
||||
}
|
||||
sel.value = agentFilter;
|
||||
}
|
||||
{
|
||||
const sel = $('flow-agent-filter');
|
||||
const sel = $("flow-agent-filter");
|
||||
if (sel) {
|
||||
sel.addEventListener('change', () => {
|
||||
sel.addEventListener("change", () => {
|
||||
agentFilter = sel.value;
|
||||
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
|
||||
else localStorage.removeItem('flow-agent-filter');
|
||||
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
|
||||
else localStorage.removeItem("flow-agent-filter");
|
||||
applyAgentFilter();
|
||||
});
|
||||
}
|
||||
|
|
@ -91,7 +97,7 @@ import { epochSec } from './util.js';
|
|||
// side effects (banner pulse, OS notifications on operator-bound
|
||||
// traffic).
|
||||
(() => {
|
||||
const flow = $('msgflow');
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
flow.replaceChildren();
|
||||
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
|
||||
|
|
@ -100,13 +106,16 @@ import { epochSec } from './util.js';
|
|||
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
||||
// there's no element to find. Kept for parity if a future chrome
|
||||
// change reintroduces a banner.
|
||||
const banner = document.querySelector('.banner');
|
||||
const banner = document.querySelector(".banner");
|
||||
let bannerOffTimer = null;
|
||||
function pulseBanner() {
|
||||
if (!banner) return;
|
||||
banner.classList.add('active');
|
||||
banner.classList.add("active");
|
||||
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
||||
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
||||
bannerOffTimer = setTimeout(
|
||||
() => banner.classList.remove("active"),
|
||||
4000,
|
||||
);
|
||||
}
|
||||
// Map of broker row id → rendered row element. Lets reply rows add
|
||||
// a visual "↳ in reply to" indicator that links back to the parent.
|
||||
|
|
@ -116,55 +125,60 @@ import { epochSec } from './util.js';
|
|||
|
||||
function renderMsg(ev, api, glyph) {
|
||||
const isReply = ev.in_reply_to != null;
|
||||
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
|
||||
const row = api.row(cls, '');
|
||||
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
|
||||
const row = api.row(cls, "");
|
||||
// Build via DOM so path anchors stay live + escape rules are
|
||||
// automatic (text nodes don't need esc()).
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
|
||||
const from = document.createElement('span');
|
||||
from.className = 'msg-from'; from.textContent = ev.from;
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'msg-sep'; sep.textContent = '→';
|
||||
const to = document.createElement('span');
|
||||
to.className = 'msg-to'; to.textContent = ev.to;
|
||||
const body = document.createElement('span');
|
||||
body.className = 'msg-body';
|
||||
const ts = document.createElement("span");
|
||||
ts.className = "msg-ts";
|
||||
ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "msg-arrow";
|
||||
arrow.textContent = glyph;
|
||||
const from = document.createElement("span");
|
||||
from.className = "msg-from";
|
||||
from.textContent = ev.from;
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "msg-sep";
|
||||
sep.textContent = "→";
|
||||
const to = document.createElement("span");
|
||||
to.className = "msg-to";
|
||||
to.textContent = ev.to;
|
||||
const body = document.createElement("span");
|
||||
body.className = "msg-body";
|
||||
appendLinkified(body, ev.body, ev.file_refs);
|
||||
// Reply thread indicator: a small "↳ reply to <from>" hint that
|
||||
// shows which message this is responding to. If we have the parent
|
||||
// in our row map, clicking scrolls it into view.
|
||||
if (isReply) {
|
||||
const replyTag = document.createElement('span');
|
||||
replyTag.className = 'msg-reply-tag';
|
||||
const replyTag = document.createElement("span");
|
||||
replyTag.className = "msg-reply-tag";
|
||||
const parentRow = msgRowMap.get(ev.in_reply_to);
|
||||
if (parentRow) {
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.textContent = '↳ reply';
|
||||
link.title = 'scroll to parent message';
|
||||
link.addEventListener('click', (e) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = "#";
|
||||
link.textContent = "↳ reply";
|
||||
link.title = "scroll to parent message";
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
parentRow.classList.add('msg-highlight');
|
||||
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
|
||||
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
parentRow.classList.add("msg-highlight");
|
||||
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
|
||||
});
|
||||
replyTag.append(link);
|
||||
} else {
|
||||
replyTag.textContent = '↳ reply';
|
||||
replyTag.textContent = "↳ reply";
|
||||
}
|
||||
row.prepend(replyTag);
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
|
||||
} else {
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
|
||||
}
|
||||
// Tag with the participants so the agent filter can match
|
||||
// on `from`/`to`, and hide the row up-front if a filter is active.
|
||||
row.dataset.from = ev.from;
|
||||
row.dataset.to = ev.to;
|
||||
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
|
||||
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
|
||||
// Register this row so future replies can reference it.
|
||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||
return row;
|
||||
|
|
@ -193,13 +207,13 @@ import { epochSec } from './util.js';
|
|||
if (ev.id == null || ev.id <= 0) return false;
|
||||
const s = recentSent.get(ev.id);
|
||||
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
|
||||
const arrow = s.row.querySelector('.msg-arrow');
|
||||
if (arrow) arrow.textContent = '✓';
|
||||
const arrow = s.row.querySelector(".msg-arrow");
|
||||
if (arrow) arrow.textContent = "✓";
|
||||
// Re-style the row as delivered (green ✓) — the collapsed line now
|
||||
// represents the delivered state; it was sent + delivered as one.
|
||||
s.row.classList.remove('sent');
|
||||
s.row.classList.add('delivered');
|
||||
s.row.title = 'sent + delivered';
|
||||
s.row.classList.remove("sent");
|
||||
s.row.classList.add("delivered");
|
||||
s.row.title = "sent + delivered";
|
||||
recentSent.delete(ev.id);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -207,11 +221,11 @@ import { epochSec } from './util.js';
|
|||
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
||||
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
||||
// gotcha (same shape on the flow page).
|
||||
const flowMain = document.querySelector('.flow-main');
|
||||
const flowMain = document.querySelector(".flow-main");
|
||||
termCreate({
|
||||
logEl: flow,
|
||||
pillAnchor: flowMain,
|
||||
historyUrl: '/api/dashboard/history',
|
||||
historyUrl: "/api/dashboard/history",
|
||||
// Server-side filter — only the kinds this page actually renders
|
||||
// or routes (sent/delivered → broker terminal,
|
||||
// container_state_changed/_removed → local autocomplete cache).
|
||||
|
|
@ -220,18 +234,19 @@ import { epochSec } from './util.js';
|
|||
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
||||
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
||||
// since it routes every mutation kind into its derived stores.
|
||||
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
||||
streamUrl:
|
||||
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
|
||||
// Route through the SharedWorker — see docs/web-ui.md (SSE
|
||||
// multiplexing paragraph). Worker keys on the full URL incl.
|
||||
// query string, so this filtered subscribe is its own upstream
|
||||
// and won't accidentally share with tabs.js's wider subscribe.
|
||||
streamFactory: openStream,
|
||||
renderers: {
|
||||
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
|
||||
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
|
||||
delivered: (ev, api) => {
|
||||
// Fold into the matching sent row when it just happened;
|
||||
// otherwise render the delivery as its own line.
|
||||
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
|
||||
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
|
||||
},
|
||||
// Maintain the local containers cache from the same stream
|
||||
// (compose autocomplete reads from `flowContainers`). The
|
||||
|
|
@ -259,21 +274,27 @@ import { epochSec } from './util.js';
|
|||
// are never replayed, so without this the compose autocomplete
|
||||
// could drift stale.
|
||||
onStreamOpen: () => {
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
flowContainers.clear();
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
}).catch(() => {});
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
flowContainers.clear();
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
onLiveEvent: (ev) => {
|
||||
pulseBanner();
|
||||
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||||
if (ev.kind === "sent" && ev.to === "operator") {
|
||||
NOTIF.show(
|
||||
'◆ ' + ev.from + ' → operator',
|
||||
String(ev.body || '').slice(0, 200),
|
||||
"◆ " + ev.from + " → operator",
|
||||
String(ev.body || "").slice(0, 200),
|
||||
// Unique-per-arrival tag so a burst stacks instead of
|
||||
// overwriting itself in the OS notification center.
|
||||
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
|
||||
"hyperhive:msg:" +
|
||||
ev.at +
|
||||
":" +
|
||||
Math.random().toString(36).slice(2, 6),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -282,28 +303,27 @@ import { epochSec } from './util.js';
|
|||
|
||||
// ─── compose: @-mention with sticky recipient ───────────────────────────
|
||||
(() => {
|
||||
const input = $('op-compose-input');
|
||||
const prompt = $('op-compose-prompt');
|
||||
const suggest = $('op-compose-suggest');
|
||||
const input = $("op-compose-input");
|
||||
const prompt = $("op-compose-prompt");
|
||||
const suggest = $("op-compose-suggest");
|
||||
if (!input || !prompt || !suggest) return;
|
||||
const STORAGE_KEY = 'hyperhive:op-compose:to';
|
||||
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
|
||||
const STORAGE_KEY = "hyperhive:op-compose:to";
|
||||
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
|
||||
let suggestActive = -1;
|
||||
function renderPrompt() {
|
||||
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
|
||||
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
|
||||
}
|
||||
function knownAgents() {
|
||||
// Read live from the flow-local containers cache so newly-spawned
|
||||
// agents become addressable without a manual reload.
|
||||
const names = Array.from(flowContainers.values())
|
||||
.map((c) => c.name);
|
||||
const names = Array.from(flowContainers.values()).map((c) => c.name);
|
||||
// `*` fans out to every registered agent (server-side
|
||||
// broadcast_send).
|
||||
names.unshift('*');
|
||||
names.unshift("*");
|
||||
return names;
|
||||
}
|
||||
function autosize() {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${input.scrollHeight}px`;
|
||||
}
|
||||
/// Parse "@name body…" — return {to, body} when the input opens
|
||||
|
|
@ -320,12 +340,15 @@ import { epochSec } from './util.js';
|
|||
}
|
||||
function renderSuggest(matches) {
|
||||
suggest.replaceChildren();
|
||||
if (!matches.length) { hideSuggest(); return; }
|
||||
if (!matches.length) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < matches.length; i += 1) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item' + (i === suggestActive ? ' active' : '');
|
||||
item.textContent = '@' + matches[i];
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "item" + (i === suggestActive ? " active" : "");
|
||||
item.textContent = "@" + matches[i];
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
applySuggestion(matches[i]);
|
||||
});
|
||||
|
|
@ -338,7 +361,7 @@ import { epochSec } from './util.js';
|
|||
const v = input.value;
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (m) {
|
||||
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
|
||||
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
|
||||
} else {
|
||||
input.value = `@${name} ` + v;
|
||||
}
|
||||
|
|
@ -353,11 +376,20 @@ import { epochSec } from './util.js';
|
|||
// input — switching recipient is always "redirect this whole
|
||||
// line." Mid-message @-mentions stay literal.
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (!m) { hideSuggest(); return; }
|
||||
if (!m) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
const partial = m[1].toLowerCase();
|
||||
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
|
||||
if (!matches.length) { hideSuggest(); return; }
|
||||
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
|
||||
const matches = knownAgents().filter((n) =>
|
||||
n.toLowerCase().startsWith(partial),
|
||||
);
|
||||
if (!matches.length) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
if (suggestActive < 0 || suggestActive >= matches.length)
|
||||
suggestActive = 0;
|
||||
renderSuggest(matches);
|
||||
}
|
||||
async function submit() {
|
||||
|
|
@ -373,20 +405,20 @@ import { epochSec } from './util.js';
|
|||
to = stickyTo;
|
||||
body = raw;
|
||||
} else {
|
||||
flashError('no recipient — start with @name to address a message');
|
||||
flashError("no recipient — start with @name to address a message");
|
||||
return;
|
||||
}
|
||||
if (!body) return;
|
||||
const fd = new FormData();
|
||||
fd.append('to', to);
|
||||
fd.append('body', body);
|
||||
fd.append("to", to);
|
||||
fd.append("body", body);
|
||||
input.disabled = true;
|
||||
try {
|
||||
// /op-send returns 200. The SSE channel carries the resulting
|
||||
// MessageEvent → the terminal renders the sent row on its own;
|
||||
// no /api/state refetch needed.
|
||||
const resp = await fetch('/api/op-send', {
|
||||
method: 'POST',
|
||||
const resp = await fetch("/api/op-send", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
|
@ -401,17 +433,17 @@ import { epochSec } from './util.js';
|
|||
}
|
||||
stickyTo = to;
|
||||
localStorage.setItem(STORAGE_KEY, to);
|
||||
input.value = '';
|
||||
input.value = "";
|
||||
autosize();
|
||||
renderPrompt();
|
||||
input.focus();
|
||||
}
|
||||
function flashError(msg) {
|
||||
const flow = $('msgflow');
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'msgrow meta';
|
||||
row.textContent = '✗ ' + msg;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msgrow meta";
|
||||
row.textContent = "✗ " + msg;
|
||||
// Append at the bottom so the error is visible — the terminal
|
||||
// renders newest-last, so inserting before firstChild would place
|
||||
// the error at the top (oldest end) and hide it from view.
|
||||
|
|
@ -420,43 +452,46 @@ import { epochSec } from './util.js';
|
|||
const wrap = flow.parentElement;
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
input.addEventListener('input', () => { autosize(); updateSuggest(); });
|
||||
input.addEventListener('keydown', (e) => {
|
||||
input.addEventListener("input", () => {
|
||||
autosize();
|
||||
updateSuggest();
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (!suggest.hidden) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const items = suggest.querySelectorAll('.item');
|
||||
if (e.key === "ArrowDown") {
|
||||
const items = suggest.querySelectorAll(".item");
|
||||
suggestActive = (suggestActive + 1) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
const items = suggest.querySelectorAll('.item');
|
||||
if (e.key === "ArrowUp") {
|
||||
const items = suggest.querySelectorAll(".item");
|
||||
suggestActive = (suggestActive - 1 + items.length) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
||||
const active = suggest.querySelector('.item.active');
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
const active = suggest.querySelector(".item.active");
|
||||
if (active) {
|
||||
applySuggestion(active.textContent.slice(1));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
hideSuggest();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.addEventListener("blur", () => {
|
||||
// Defer so a click on a suggestion item (mousedown) lands first.
|
||||
setTimeout(hideSuggest, 100);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue