feat: Command-Palette-Modul (Strg+K, read-only Bookmark-Suche)
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — palette.js
|
||||
Command-Palette (Strg+K): read-only Suche ueber Boards/Bookmarks
|
||||
============================================= */
|
||||
|
||||
// Reine init-Funktion ohne Top-Level-Seiteneffekt (Hausmuster wie initSearch).
|
||||
// Wird aus app.js init() nach initSearch() aufgerufen.
|
||||
function initPalette() {
|
||||
let overlay = null; // aktives Overlay-Element oder null (= geschlossen)
|
||||
let prevFocus = null; // Fokus-Rueckgabeziel
|
||||
let keyHandler = null; // dokument-weiter Handler im offenen Zustand
|
||||
let results = []; // aktuelle Trefferliste [{ title, url, boardName }]
|
||||
let activeIndex = -1; // aktiver Listeneintrag fuer aria-activedescendant
|
||||
|
||||
// ---- Open-Guard: kein Strg+K wenn ein anderes Overlay offen ist ----
|
||||
// Deckt Settings (.panel-overlay), Theme/Add-Board/Add-Bookmark/Rename
|
||||
// (.modal-overlay) sowie HellionDialog UND Onboarding (.dialog-overlay) ab.
|
||||
function isBlocked() {
|
||||
return !!document.querySelector(
|
||||
'.panel-overlay.active, .modal-overlay.active, .dialog-overlay.active'
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Trefferquelle: flach ueber alle Boards/Bookmarks ----
|
||||
// Read-only auf dem globalen boards-Array. Match auf Titel, URL, Board-Name.
|
||||
function search(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const out = [];
|
||||
for (const board of boards) {
|
||||
const boardName = board.title || '';
|
||||
const boardMatch = boardName.toLowerCase().includes(q);
|
||||
for (const bm of (board.bookmarks || [])) {
|
||||
const title = bm.title || '';
|
||||
const url = bm.url || '';
|
||||
if (
|
||||
title.toLowerCase().includes(q) ||
|
||||
url.toLowerCase().includes(q) ||
|
||||
boardMatch
|
||||
) {
|
||||
out.push({ title, url, boardName });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Treffer oeffnen (wie boards.js:270) ----
|
||||
function openResult(item) {
|
||||
if (!item || !item.url) return;
|
||||
window.open(item.url, settings.newTab ? '_blank' : '_self', 'noopener,noreferrer');
|
||||
close();
|
||||
}
|
||||
|
||||
// ---- Listbox neu rendern ----
|
||||
function renderList(listEl, liveEl) {
|
||||
listEl.textContent = '';
|
||||
activeIndex = results.length ? 0 : -1;
|
||||
|
||||
if (results.length === 0) {
|
||||
const empty = document.createElement('li');
|
||||
empty.className = 'palette-empty';
|
||||
empty.id = 'palette-opt-empty';
|
||||
empty.setAttribute('role', 'option');
|
||||
empty.setAttribute('aria-disabled', 'true');
|
||||
empty.textContent = t('palette.no_results');
|
||||
listEl.appendChild(empty);
|
||||
liveEl.textContent = t('palette.no_results');
|
||||
return;
|
||||
}
|
||||
|
||||
results.forEach((item, i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'palette-option';
|
||||
li.id = 'palette-opt-' + i;
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'palette-option-title';
|
||||
titleSpan.textContent = item.title;
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'palette-option-meta';
|
||||
metaSpan.textContent = t('palette.board_prefix') + ' ' + item.boardName;
|
||||
|
||||
li.append(titleSpan, metaSpan);
|
||||
// Pointer-Auswahl: Klick oeffnet, Hover markiert
|
||||
li.addEventListener('click', () => openResult(item));
|
||||
li.addEventListener('mousemove', () => setActive(listEl, i));
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
|
||||
const count = results.length;
|
||||
liveEl.textContent = count === 1 ? t('palette.count_one') : t('palette.count', { count });
|
||||
}
|
||||
|
||||
// ---- aktiven Eintrag setzen (aria-activedescendant + aria-selected) ----
|
||||
function setActive(listEl, idx) {
|
||||
const options = listEl.querySelectorAll('.palette-option');
|
||||
if (options.length === 0) return;
|
||||
activeIndex = Math.max(0, Math.min(idx, options.length - 1));
|
||||
options.forEach((opt, i) => {
|
||||
opt.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false');
|
||||
});
|
||||
const input = overlay.querySelector('.palette-input');
|
||||
input.setAttribute('aria-activedescendant', options[activeIndex].id);
|
||||
options[activeIndex].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
// ---- schliessen: dialog.js-Cleanup-Muster (remove Listener -> Transition -> Fokus) ----
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
document.removeEventListener('keydown', keyHandler);
|
||||
const el = overlay;
|
||||
overlay = null;
|
||||
keyHandler = null;
|
||||
withViewTransition(() => {
|
||||
el.classList.remove('active');
|
||||
el.remove();
|
||||
});
|
||||
if (prevFocus && typeof prevFocus.focus === 'function') prevFocus.focus();
|
||||
prevFocus = null;
|
||||
}
|
||||
|
||||
// ---- oeffnen: Overlay nach dialog.js-Muster aufbauen ----
|
||||
function open() {
|
||||
if (overlay) return;
|
||||
prevFocus = document.activeElement;
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'dialog-overlay palette-overlay';
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'palette-box';
|
||||
box.setAttribute('role', 'combobox');
|
||||
box.setAttribute('aria-haspopup', 'listbox');
|
||||
box.setAttribute('aria-expanded', 'true');
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'palette-input';
|
||||
input.type = 'text';
|
||||
input.setAttribute('role', 'searchbox');
|
||||
input.setAttribute('aria-label', t('palette.aria_label'));
|
||||
input.setAttribute('aria-autocomplete', 'list');
|
||||
input.setAttribute('aria-controls', 'palette-listbox');
|
||||
input.placeholder = t('palette.placeholder');
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'palette-list';
|
||||
list.id = 'palette-listbox';
|
||||
list.setAttribute('role', 'listbox');
|
||||
list.setAttribute('aria-label', t('palette.list_label'));
|
||||
|
||||
const live = document.createElement('div');
|
||||
live.className = 'palette-live';
|
||||
live.setAttribute('aria-live', 'polite');
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'palette-hint';
|
||||
hint.textContent = t('palette.hint');
|
||||
|
||||
box.append(input, list, live, hint);
|
||||
overlay.appendChild(box);
|
||||
|
||||
// Klick auf den Overlay-Hintergrund schliesst (wie dialog.js:107)
|
||||
overlay.addEventListener('click', e => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
// Live-Filter
|
||||
input.addEventListener('input', () => {
|
||||
results = search(input.value);
|
||||
renderList(list, live);
|
||||
input.setAttribute('aria-activedescendant', activeIndex >= 0 ? 'palette-opt-' + activeIndex : '');
|
||||
});
|
||||
|
||||
// Tastatursteuerung: Pfeile/Enter/Escape + Fokus-Falle (nur input fokussierbar)
|
||||
keyHandler = function (e) {
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && results[activeIndex]) openResult(results[activeIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(list, activeIndex + 1); return; }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); setActive(list, activeIndex - 1); return; }
|
||||
if (e.key === 'Tab') {
|
||||
// Fokus-Falle ueber den Container: das Eingabefeld ist das einzige
|
||||
// fokussierbare Element, also Tab/Shift+Tab immer dorthin zurueck.
|
||||
e.preventDefault();
|
||||
input.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
// View-Transition uebernimmt das Fade; Fokus ins Eingabefeld
|
||||
withViewTransition(() => {
|
||||
overlay.classList.add('active');
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- globaler Ausloeser Strg+K (Meta+K auf Mac) mit Open-Guard ----
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
if (overlay) { e.preventDefault(); close(); return; }
|
||||
if (isBlocked()) return;
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user