Files
Vito a52ff4e052 feat(ui): add Focused Prompt Manager with shared autocomplete (#191)
* feat(ui): add Focused Prompt Manager node (#186)

* feat(ui): autocomplete saved Focused metadata with shared controls (#186)

* feat(ui): autocomplete Focused library filters (#186)
2026-10-17 05:31:24 -07:00

86 lines
4.4 KiB
JavaScript

import {createScope} from './shared/toolkit/core.js';
import {createSuggestionView} from './shared/toolkit/suggestions.js';
export function attachMetadataAutocomplete(input, {scope: owner, api, settings, multiple = false, available = () => true}) {
const scope = createScope();
owner.cleanup(() => scope.dispose());
const view = createSuggestionView(scope, settings, {label: multiple ? 'Saved tags' : 'Saved categories'});
const attributes = ['role','aria-autocomplete','aria-controls','aria-expanded','aria-activedescendant','autocomplete'];
const original = new Map(attributes.map(name => [name,input.getAttribute(name)]));
input.setAttribute('role','combobox');
input.setAttribute('aria-autocomplete','list');
input.setAttribute('aria-controls',view.element.id);
input.setAttribute('aria-expanded','false');
input.autocomplete = 'off';
let timer, request, sequence = 0, items = [], selected = -1;
function close() {
clearTimeout(timer); request?.abort(); sequence++;
items = []; selected = -1; view.hide();
input.setAttribute('aria-expanded','false');
input.removeAttribute('aria-activedescendant');
}
scope.cleanup(() => {
close();
original.forEach((value,name) => value === null ? input.removeAttribute(name) : input.setAttribute(name,value));
});
function choose(index) {
selected = index;
input.setAttribute('aria-activedescendant',view.select(index));
}
function insert(item) {
if (!available()) return close();
const parts = input.value.split(',');
parts.pop();
input.value = multiple ? [...parts.map(part => part.trim()).filter(Boolean),item.name].join(', ') + ', ' : item.name;
input.dispatchEvent(new Event('input',{bubbles:true}));
close(); input.focus();
}
async function load() {
const current = ++sequence;
request = new AbortController();
const parts = input.value.split(',');
const query = (multiple ? parts.pop() : input.value).trim();
const committed = new Set(parts.map(part => part.trim().toLowerCase()));
const url = multiple ? `/prompt_manager/tags/suggest?${new URLSearchParams({q:query,limit:'20'})}` : '/prompt_manager/categories';
try {
const response = await fetch(api.fileURL(url),{signal:request.signal});
if (scope.signal.aborted || current !== sequence) return;
if (!response.ok) return close();
const data = await response.json();
if (scope.signal.aborted || current !== sequence || document.activeElement !== input || !available()) return;
if (!data.success) return close();
items = (multiple ? data.suggestions : data.categories).map(item => ({name:typeof item === 'string' ? item : item.name}))
.filter(item => item.name.toLowerCase().startsWith(query.toLowerCase()) && (!multiple || !committed.has(item.name.toLowerCase()))).slice(0,15);
if (!items.length) return close();
selected = -1;
view.render(items,choose,insert); view.position(input.getBoundingClientRect());
input.setAttribute('aria-expanded','true');
} catch {
if (current === sequence && !scope.signal.aborted) close();
}
}
function schedule() {
close();
if (available()) timer = setTimeout(load,150);
}
scope.listen(input,'input',schedule);
scope.listen(input,'focus',schedule);
scope.listen(input,'blur',close);
scope.listen(input,'keydown',event => {
if (event.isComposing) return;
if (event.key === 'Escape' || event.key === 'Tab') {close();return;}
if (!items.length) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault(); event.stopPropagation();
choose(selected < 0 ? (event.key === 'ArrowDown' ? 0 : items.length - 1) : (selected + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length);
} else if (event.key === 'Enter' && selected >= 0) {
event.preventDefault(); event.stopPropagation(); insert(items[selected]);
}
});
scope.listen(window,'resize',close);
document.addEventListener('scroll',event => {
if (!view.element.hidden && !view.element.contains(event.target)) close();
},{capture:true,signal:scope.signal});
return {close};
}