From 42860bb95d412f67d7d008d72d30424c9e7d8f65 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sun, 14 Jun 2026 09:15:21 +0200 Subject: [PATCH] feat: Command-Palette-Modul (Strg+K, read-only Bookmark-Suche) --- src/js/palette.js | 216 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/js/palette.js diff --git a/src/js/palette.js b/src/js/palette.js new file mode 100644 index 0000000..76a05bb --- /dev/null +++ b/src/js/palette.js @@ -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(); + } + }); +}