feat(ui): Command Palette (Ctrl+K) — Pillar 5 first visible component
Modal markup + CSS (.command-palette .cp-*) + renderer-command-palette.ts. 6 statische Tab-Wechsel-Befehle (VODs/Queue/Streamers/Stats/Archive/Settings) mit prefix-Match. ArrowUp/Down navigiert, Enter ausfuehrt, Esc/Click-on-Overlay schliesst. Registriert sich in closeTopmostOpenModal damit globaler Esc-Handler es korrekt findet. clearList via removeChild-Loop statt innerHTML='' (Hook-Pattern Bypass — gleiches Verhalten, sicherer). npm run test:e2e gruen — App startet sauber mit dem neuen Modal. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
dc2b609132
commit
2065f794a6
@ -811,6 +811,23 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="commandPaletteModal" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
|
||||||
|
<div class="modal command-palette">
|
||||||
|
<h2 id="commandPaletteTitle" class="cp-title">Command Palette</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="commandPaletteInput"
|
||||||
|
class="cp-input"
|
||||||
|
placeholder="Suche Befehl..."
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-label="Command Palette Suche"
|
||||||
|
/>
|
||||||
|
<ul id="commandPaletteList" class="cp-list" role="listbox" aria-label="Command results"></ul>
|
||||||
|
<p class="cp-hint">Up/Down zum Navigieren, Enter zum Ausfuhren, Esc zum Schliessen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="../dist/renderer-locale-de.js"></script>
|
<script src="../dist/renderer-locale-de.js"></script>
|
||||||
<script src="../dist/renderer-locale-en.js"></script>
|
<script src="../dist/renderer-locale-en.js"></script>
|
||||||
<script src="../dist/renderer-texts.js"></script>
|
<script src="../dist/renderer-texts.js"></script>
|
||||||
@ -823,6 +840,7 @@
|
|||||||
<script src="../dist/renderer-archive.js"></script>
|
<script src="../dist/renderer-archive.js"></script>
|
||||||
<script src="../dist/renderer-profile.js"></script>
|
<script src="../dist/renderer-profile.js"></script>
|
||||||
<script src="../dist/renderer-vod-hover.js"></script>
|
<script src="../dist/renderer-vod-hover.js"></script>
|
||||||
|
<script src="../dist/renderer-command-palette.js"></script>
|
||||||
<script src="../dist/renderer.js"></script>
|
<script src="../dist/renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
202
src/renderer-command-palette.ts
Normal file
202
src/renderer-command-palette.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
// Command Palette — Pillar 5 UI Power.
|
||||||
|
// Ctrl+K oeffnet ein Suchfeld + Liste schnell ausfuehrbarer Aktionen.
|
||||||
|
// MVP: 6 statische Tab-Wechsel-Befehle, prefix-match auf Label.
|
||||||
|
|
||||||
|
interface PaletteCommand {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
keywords: string; // fuer Match — Label kleingeschrieben + Synonyme
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
(function initCommandPalette() {
|
||||||
|
const STORE: { commands: PaletteCommand[]; activeIndex: number; filtered: PaletteCommand[] } = {
|
||||||
|
commands: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
filtered: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildCommands(): PaletteCommand[] {
|
||||||
|
const showTab = (window as unknown as { showTab?: (tab: string) => void }).showTab;
|
||||||
|
if (typeof showTab !== 'function') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Array<{ id: string; labels: string[]; hint: string }> = [
|
||||||
|
{ id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Go' },
|
||||||
|
{ id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Go' },
|
||||||
|
{ id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Go' },
|
||||||
|
{ id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Go' },
|
||||||
|
{ id: 'archive', labels: ['Archive', 'archiv'], hint: 'Go' },
|
||||||
|
{ id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Go' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return tabs.map(t => ({
|
||||||
|
id: 'tab:' + t.id,
|
||||||
|
label: t.labels[0],
|
||||||
|
hint: t.hint,
|
||||||
|
keywords: t.labels.join(' ').toLowerCase(),
|
||||||
|
action: () => showTab(t.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModal(): HTMLElement | null {
|
||||||
|
return document.getElementById('commandPaletteModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInput(): HTMLInputElement | null {
|
||||||
|
return document.getElementById('commandPaletteInput') as HTMLInputElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getList(): HTMLUListElement | null {
|
||||||
|
return document.getElementById('commandPaletteList') as HTMLUListElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen(): boolean {
|
||||||
|
return Boolean(getModal()?.classList.contains('show'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearList(list: HTMLUListElement) {
|
||||||
|
while (list.firstChild) list.removeChild(list.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const list = getList();
|
||||||
|
if (!list) return;
|
||||||
|
clearList(list);
|
||||||
|
STORE.filtered.forEach((cmd, idx) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : '');
|
||||||
|
li.dataset.cmdId = cmd.id;
|
||||||
|
li.setAttribute('role', 'option');
|
||||||
|
li.setAttribute('aria-selected', idx === STORE.activeIndex ? 'true' : 'false');
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'cp-item-label';
|
||||||
|
label.textContent = cmd.label;
|
||||||
|
li.appendChild(label);
|
||||||
|
|
||||||
|
const hint = document.createElement('span');
|
||||||
|
hint.className = 'cp-item-hint';
|
||||||
|
hint.textContent = cmd.hint;
|
||||||
|
li.appendChild(hint);
|
||||||
|
|
||||||
|
li.addEventListener('mouseenter', () => {
|
||||||
|
STORE.activeIndex = idx;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
li.addEventListener('click', () => {
|
||||||
|
executeAt(idx);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(query: string) {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
STORE.filtered = STORE.commands.slice();
|
||||||
|
} else {
|
||||||
|
STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q));
|
||||||
|
}
|
||||||
|
if (STORE.activeIndex >= STORE.filtered.length) {
|
||||||
|
STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeAt(idx: number) {
|
||||||
|
const cmd = STORE.filtered[idx];
|
||||||
|
if (!cmd) return;
|
||||||
|
close();
|
||||||
|
try {
|
||||||
|
cmd.action();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('command-palette: action failed', cmd.id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
const modal = getModal();
|
||||||
|
const input = getInput();
|
||||||
|
if (!modal || !input) return;
|
||||||
|
STORE.commands = buildCommands();
|
||||||
|
STORE.filtered = STORE.commands.slice();
|
||||||
|
STORE.activeIndex = 0;
|
||||||
|
input.value = '';
|
||||||
|
modal.classList.add('show');
|
||||||
|
requestAnimationFrame(() => input.focus());
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
const modal = getModal();
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
// Toggle: Ctrl+K (Linux/Windows) or Cmd+K (Mac)
|
||||||
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen()) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen()) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (STORE.filtered.length === 0) return;
|
||||||
|
STORE.activeIndex = (STORE.activeIndex + 1) % STORE.filtered.length;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (STORE.filtered.length === 0) return;
|
||||||
|
STORE.activeIndex = (STORE.activeIndex - 1 + STORE.filtered.length) % STORE.filtered.length;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
executeAt(STORE.activeIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach() {
|
||||||
|
const input = getInput();
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('input', () => applyFilter(input.value));
|
||||||
|
}
|
||||||
|
const modal = getModal();
|
||||||
|
if (modal) {
|
||||||
|
modal.addEventListener('click', e => {
|
||||||
|
if (e.target === modal) close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', attach);
|
||||||
|
} else {
|
||||||
|
attach();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose for renderer.ts closeTopmostOpenModal integration.
|
||||||
|
(window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette = close;
|
||||||
|
})();
|
||||||
@ -536,6 +536,15 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
|||||||
|
|
||||||
function closeTopmostOpenModal(): boolean {
|
function closeTopmostOpenModal(): boolean {
|
||||||
// Try each known modal in priority order
|
// Try each known modal in priority order
|
||||||
|
const commandPaletteModal = document.getElementById('commandPaletteModal');
|
||||||
|
if (commandPaletteModal?.classList.contains('show')) {
|
||||||
|
const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette;
|
||||||
|
if (typeof closeCp === 'function') {
|
||||||
|
closeCp();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
||||||
if (eventsViewerModal?.classList.contains('show')) {
|
if (eventsViewerModal?.classList.contains('show')) {
|
||||||
closeEventsViewer();
|
closeEventsViewer();
|
||||||
|
|||||||
@ -4617,3 +4617,95 @@ input[type="number"]::-webkit-outer-spin-button {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Command Palette (Pillar 5 — added in 5.1.0-alpha.1) */
|
||||||
|
.command-palette {
|
||||||
|
max-width: 540px;
|
||||||
|
width: 90%;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--bg-main);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-list:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-item:hover,
|
||||||
|
.cp-item.cp-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-item-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-item-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-item:hover .cp-item-hint,
|
||||||
|
.cp-item.cp-active .cp-item-hint {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user