feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044)

Implements the design from docs/design-command-palette.md. Adds a fzf-style
command palette on top of the existing global search overlay:

- Cmd+K (Mac) / Ctrl+K (Win/Lin) opens the palette in discoverability mode
  (all 20 actions visible, no entity fetch). Existing "/" shortcut preserved.
- preventDefault + stopPropagation suppress browser-native Ctrl+K behavior
  (Firefox URL-bar focus). Cmd+K explicitly ignores the in-text-input skip
  rule so power users can open the palette from anywhere.
- Action catalog (frontend/src/client/palette-actions.ts) — 12 navigate +
  3 create + 4 toggle/app actions. Substring filter on DE+EN labels (no
  fuzzy lib). runAction dispatcher reuses existing DOM handlers (lang
  toggle button, sidebar pin, invite modal) — no duplicated state.
- Filtered state shows actions on top, entity search results below.
- First item auto-selected so ↵ works without an arrow press first.
- Footer shows ↑↓ / ↵ / Esc kbd hints (hidden on <480px viewports).
- 25 i18n keys (DE + EN) under palette.action.* / palette.section.* /
  palette.footer.*.
- Mobile: BottomNav stays as-is (5 slots full); palette accessed via the
  drawer search input. Documented decision in design doc.

Build / vet / test all clean. Smoke verified on local: login page loads
with no console errors, palette code is bundled into authenticated page
JS bundles. Production verification via Playwright after Dokploy
auto-deploy.
This commit is contained in:
m
2026-04-26 15:15:58 +02:00
parent c226a8b14d
commit 75b52d49ba
5 changed files with 497 additions and 57 deletions

View File

@@ -767,6 +767,31 @@ const translations: Record<Lang, Record<string, string>> = {
"search.group.links": "Links",
"search.group.users": "Kolleg:innen",
// Command palette (t-paliad-044)
"palette.section.actions": "Aktionen",
"palette.action.nav.dashboard": "Gehe zu Dashboard",
"palette.action.nav.projects": "Gehe zu Projekte",
"palette.action.nav.deadlines": "Gehe zu Fristen",
"palette.action.nav.appointments": "Gehe zu Termine",
"palette.action.nav.agenda": "Gehe zu Agenda",
"palette.action.nav.team": "Gehe zu Team",
"palette.action.nav.glossary": "Gehe zu Glossar",
"palette.action.nav.courts": "Gehe zu Gerichte",
"palette.action.nav.links": "Gehe zu Links",
"palette.action.nav.checklists": "Gehe zu Checklisten",
"palette.action.nav.downloads": "Gehe zu Downloads",
"palette.action.nav.settings": "Gehe zu Einstellungen",
"palette.action.create.deadline": "Neue Frist anlegen",
"palette.action.create.appointment": "Neuer Termin anlegen",
"palette.action.create.project": "Neues Projekt anlegen",
"palette.action.toggle.lang": "Sprache umschalten",
"palette.action.toggle.pin": "Sidebar an-/abheften",
"palette.action.app.invite": "Kolleg:in einladen",
"palette.action.app.logout": "Abmelden",
"palette.footer.navigate": "Navigieren",
"palette.footer.open": "Öffnen",
"palette.footer.close": "Schließen",
// Settings page (t-paliad-022)
"einstellungen.title": "Einstellungen \u2014 Paliad",
"einstellungen.heading": "Einstellungen",
@@ -1903,6 +1928,31 @@ const translations: Record<Lang, Record<string, string>> = {
"search.group.links": "Links",
"search.group.users": "Colleagues",
// Command palette (t-paliad-044)
"palette.section.actions": "Actions",
"palette.action.nav.dashboard": "Go to Dashboard",
"palette.action.nav.projects": "Go to Projects",
"palette.action.nav.deadlines": "Go to Deadlines",
"palette.action.nav.appointments": "Go to Appointments",
"palette.action.nav.agenda": "Go to Agenda",
"palette.action.nav.team": "Go to Team",
"palette.action.nav.glossary": "Go to Glossary",
"palette.action.nav.courts": "Go to Courts",
"palette.action.nav.links": "Go to Links",
"palette.action.nav.checklists": "Go to Checklists",
"palette.action.nav.downloads": "Go to Downloads",
"palette.action.nav.settings": "Go to Settings",
"palette.action.create.deadline": "New deadline",
"palette.action.create.appointment": "New appointment",
"palette.action.create.project": "New project",
"palette.action.toggle.lang": "Toggle language",
"palette.action.toggle.pin": "Pin / unpin sidebar",
"palette.action.app.invite": "Invite a colleague",
"palette.action.app.logout": "Logout",
"palette.footer.navigate": "Navigate",
"palette.footer.open": "Open",
"palette.footer.close": "Close",
// Settings page (t-paliad-022)
"einstellungen.title": "Settings \u2014 Paliad",
"einstellungen.heading": "Settings",

View File

@@ -0,0 +1,202 @@
// Cmd/Ctrl+K command palette — action catalog (t-paliad-044).
//
// Every action shown above the entity-search results is defined here. Adding /
// removing actions = editing this file. The dispatcher (`runAction`) reuses
// existing DOM handlers (lang toggle button, sidebar pin, invite modal) rather
// than duplicating their state, so the source of truth for each behavior stays
// where it already lives.
//
// Substring filter is intentionally simple — no fuzzy match, no token
// permutation. Matches against BOTH the current-language label and the other-
// language label so a German speaker who types "deadline" still finds Fristen.
import { getLang } from "./i18n";
export type ActionGroup = "navigate" | "create" | "toggle";
export interface PaletteAction {
id: string;
i18nKey: string;
fallbackDe: string;
fallbackEn: string;
iconKey: ActionIconKey;
group: ActionGroup;
}
// Stroke-based icons matching the sidebar/search aesthetic (1.5 width,
// currentColor). Picked per action; reused where the meaning maps.
export type ActionIconKey =
| "dashboard"
| "folder"
| "clock"
| "calendar"
| "agenda"
| "users"
| "book"
| "building"
| "link"
| "check"
| "download"
| "gear"
| "plus"
| "globe"
| "pin"
| "mail"
| "logout";
export const PALETTE_ACTIONS: PaletteAction[] = [
// Navigate
{ id: "nav.dashboard", i18nKey: "palette.action.nav.dashboard", fallbackDe: "Gehe zu Dashboard", fallbackEn: "Go to Dashboard", iconKey: "dashboard", group: "navigate" },
{ id: "nav.projects", i18nKey: "palette.action.nav.projects", fallbackDe: "Gehe zu Projekte", fallbackEn: "Go to Projects", iconKey: "folder", group: "navigate" },
{ id: "nav.deadlines", i18nKey: "palette.action.nav.deadlines", fallbackDe: "Gehe zu Fristen", fallbackEn: "Go to Deadlines", iconKey: "clock", group: "navigate" },
{ id: "nav.appointments", i18nKey: "palette.action.nav.appointments", fallbackDe: "Gehe zu Termine", fallbackEn: "Go to Appointments", iconKey: "calendar", group: "navigate" },
{ id: "nav.agenda", i18nKey: "palette.action.nav.agenda", fallbackDe: "Gehe zu Agenda", fallbackEn: "Go to Agenda", iconKey: "agenda", group: "navigate" },
{ id: "nav.team", i18nKey: "palette.action.nav.team", fallbackDe: "Gehe zu Team", fallbackEn: "Go to Team", iconKey: "users", group: "navigate" },
{ id: "nav.glossary", i18nKey: "palette.action.nav.glossary", fallbackDe: "Gehe zu Glossar", fallbackEn: "Go to Glossary", iconKey: "book", group: "navigate" },
{ id: "nav.courts", i18nKey: "palette.action.nav.courts", fallbackDe: "Gehe zu Gerichte", fallbackEn: "Go to Courts", iconKey: "building", group: "navigate" },
{ id: "nav.links", i18nKey: "palette.action.nav.links", fallbackDe: "Gehe zu Links", fallbackEn: "Go to Links", iconKey: "link", group: "navigate" },
{ id: "nav.checklists", i18nKey: "palette.action.nav.checklists", fallbackDe: "Gehe zu Checklisten", fallbackEn: "Go to Checklists", iconKey: "check", group: "navigate" },
{ id: "nav.downloads", i18nKey: "palette.action.nav.downloads", fallbackDe: "Gehe zu Downloads", fallbackEn: "Go to Downloads", iconKey: "download", group: "navigate" },
{ id: "nav.settings", i18nKey: "palette.action.nav.settings", fallbackDe: "Gehe zu Einstellungen", fallbackEn: "Go to Settings", iconKey: "gear", group: "navigate" },
// Create
{ id: "create.deadline", i18nKey: "palette.action.create.deadline", fallbackDe: "Neue Frist anlegen", fallbackEn: "New deadline", iconKey: "plus", group: "create" },
{ id: "create.appointment", i18nKey: "palette.action.create.appointment", fallbackDe: "Neuer Termin anlegen", fallbackEn: "New appointment", iconKey: "plus", group: "create" },
{ id: "create.project", i18nKey: "palette.action.create.project", fallbackDe: "Neues Projekt anlegen", fallbackEn: "New project", iconKey: "plus", group: "create" },
// Toggle / app actions
{ id: "toggle.lang", i18nKey: "palette.action.toggle.lang", fallbackDe: "Sprache umschalten", fallbackEn: "Toggle language", iconKey: "globe", group: "toggle" },
{ id: "toggle.pin", i18nKey: "palette.action.toggle.pin", fallbackDe: "Sidebar an-/abheften", fallbackEn: "Pin / unpin sidebar", iconKey: "pin", group: "toggle" },
{ id: "app.invite", i18nKey: "palette.action.app.invite", fallbackDe: "Kolleg:in einladen", fallbackEn: "Invite a colleague", iconKey: "mail", group: "toggle" },
{ id: "app.logout", i18nKey: "palette.action.app.logout", fallbackDe: "Abmelden", fallbackEn: "Logout", iconKey: "logout", group: "toggle" },
];
// labelFor returns the active-language label for an action, falling back to
// the hard-coded fallbacks if i18n.ts hasn't loaded the key yet.
export function labelFor(action: PaletteAction, t: (k: string) => string): string {
const lang = getLang();
const translated = t(action.i18nKey);
if (translated && translated !== action.i18nKey) return translated;
return lang === "en" ? action.fallbackEn : action.fallbackDe;
}
// filterActions runs a substring match against BOTH language fallbacks plus the
// translated label. Sorted: prefix-match > substring-match, ties broken by
// catalog order. Empty query returns the full catalog.
export function filterActions(query: string, t: (k: string) => string): PaletteAction[] {
const q = query.trim().toLowerCase();
if (!q) return PALETTE_ACTIONS.slice();
type Scored = { action: PaletteAction; score: number; idx: number };
const scored: Scored[] = [];
PALETTE_ACTIONS.forEach((action, idx) => {
const haystacks = [
labelFor(action, t).toLowerCase(),
action.fallbackDe.toLowerCase(),
action.fallbackEn.toLowerCase(),
];
let bestScore = -1;
for (const h of haystacks) {
if (h.startsWith(q)) {
bestScore = Math.max(bestScore, 2);
break;
}
if (h.includes(q)) {
bestScore = Math.max(bestScore, 1);
}
}
if (bestScore >= 0) scored.push({ action, score: bestScore, idx });
});
scored.sort((a, b) => (b.score - a.score) || (a.idx - b.idx));
return scored.map((s) => s.action);
}
// runAction dispatches to the underlying behavior. Reuses existing DOM
// handlers wherever possible — sidebar pin, lang toggle, invite modal — so the
// source of truth stays where the feature lives.
export function runAction(id: string): void {
switch (id) {
case "nav.dashboard": window.location.href = "/dashboard"; return;
case "nav.projects": window.location.href = "/projects"; return;
case "nav.deadlines": window.location.href = "/deadlines"; return;
case "nav.appointments": window.location.href = "/appointments"; return;
case "nav.agenda": window.location.href = "/agenda"; return;
case "nav.team": window.location.href = "/team"; return;
case "nav.glossary": window.location.href = "/glossary"; return;
case "nav.courts": window.location.href = "/courts"; return;
case "nav.links": window.location.href = "/links"; return;
case "nav.checklists": window.location.href = "/checklists"; return;
case "nav.downloads": window.location.href = "/downloads"; return;
case "nav.settings": window.location.href = "/settings"; return;
case "create.deadline": window.location.href = "/deadlines/new"; return;
case "create.appointment": window.location.href = "/appointments/new"; return;
case "create.project": window.location.href = "/projects/new"; return;
case "toggle.lang": {
const cur = getLang();
const next = cur === "de" ? "en" : "de";
const btn = document.querySelector<HTMLButtonElement>(`[data-lang-toggle="${next}"]`);
btn?.click();
return;
}
case "toggle.pin": {
const btn = document.querySelector<HTMLButtonElement>(".sidebar-pin");
btn?.click();
return;
}
case "app.invite": {
const btn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
btn?.click();
return;
}
case "app.logout":
window.location.href = "/logout";
return;
}
}
// iconFor returns a tiny inline SVG. Stroke-based, 1.5 width, currentColor.
// Mirrors the sidebar/search icon language so the palette feels native.
export function iconForAction(key: ActionIconKey): string {
const a = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"';
switch (key) {
case "dashboard":
return `<svg ${a}><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>`;
case "folder":
return `<svg ${a}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`;
case "clock":
return `<svg ${a}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
case "calendar":
return `<svg ${a}><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`;
case "agenda":
return `<svg ${a}><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="7" y1="13" x2="13" y2="13"/><line x1="7" y1="17" x2="17" y2="17"/></svg>`;
case "users":
return `<svg ${a}><circle cx="9" cy="8" r="4"/><path d="M2 21v-1a6 6 0 0 1 6-6h2a6 6 0 0 1 6 6v1"/><path d="M16 3a4 4 0 0 1 0 8"/><path d="M22 21v-1a6 6 0 0 0-4-5.66"/></svg>`;
case "book":
return `<svg ${a}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>`;
case "building":
return `<svg ${a}><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/></svg>`;
case "link":
return `<svg ${a}><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
case "check":
return `<svg ${a}><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`;
case "download":
return `<svg ${a}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
case "gear":
return `<svg ${a}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
case "plus":
return `<svg ${a}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
case "globe":
return `<svg ${a}><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`;
case "pin":
return `<svg ${a}><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a1 1 0 0 0 0-2H8a1 1 0 0 0 0 2h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>`;
case "mail":
return `<svg ${a}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`;
case "logout":
return `<svg ${a}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`;
}
return "";
}

View File

@@ -1,8 +1,21 @@
// Global search — client-side controller for the sidebar search input.
// Global search palette — client-side controller for the sidebar search input.
// Shared by every page that renders <Sidebar />. Owns debounce, keyboard
// shortcuts, overlay rendering, and navigation.
//
// Cmd+K / Ctrl+K (t-paliad-044) opens the palette in "discoverability mode":
// the action catalog is rendered with no entity fetch. Once the user types,
// the entity search fires alongside a substring filter on the actions.
// "/" is preserved as a second trigger (existing muscle memory; t-paliad-026).
import { t, onLangChange } from "./i18n";
import {
PALETTE_ACTIONS,
type PaletteAction,
filterActions,
iconForAction,
labelFor,
runAction,
} from "./palette-actions";
type ResultType =
| "project"
@@ -35,6 +48,12 @@ interface SearchResponse {
users: SearchResult[];
}
// Active item is either a palette action or an entity result. ↵ dispatches
// based on `kind`.
type ActiveItem =
| { kind: "action"; action: PaletteAction }
| { kind: "entity"; result: SearchResult };
// Debounce is tight enough to feel responsive but coarse enough to avoid
// hammering the backend on every keystroke. 200ms matches the task brief.
const DEBOUNCE_MS = 200;
@@ -53,9 +72,10 @@ const GROUP_ORDER: Array<{ key: keyof SearchResponse; i18n: string; fallback: st
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let inFlight: AbortController | null = null;
let flatResults: SearchResult[] = [];
let flatResults: ActiveItem[] = [];
let activeIndex = -1;
let currentResponse: SearchResponse | null = null;
let currentQuery = "";
export function initGlobalSearch(): void {
const input = document.getElementById("global-search-input") as HTMLInputElement | null;
@@ -64,14 +84,33 @@ export function initGlobalSearch(): void {
input.addEventListener("input", () => {
const q = input.value.trim();
currentQuery = q;
if (debounceTimer) clearTimeout(debounceTimer);
if (q.length < MIN_QUERY) {
closeOverlay(overlay);
// Empty input but palette still open — show all actions, no entity fetch.
if (overlay.style.display === "block") {
currentResponse = null;
renderPalette(q, null, overlay);
} else {
closeOverlay(overlay);
}
return;
}
debounceTimer = setTimeout(() => runSearch(q, overlay), DEBOUNCE_MS);
});
input.addEventListener("focus", () => {
// Render whatever state we currently have. If the user just clicked into
// the input with nothing typed, this opens the palette in empty-action
// mode — same UX as Cmd+K.
const q = input.value.trim();
currentQuery = q;
if (q.length < MIN_QUERY) {
currentResponse = null;
renderPalette(q, null, overlay);
}
});
input.addEventListener("keydown", (e) => {
switch (e.key) {
case "Escape":
@@ -105,8 +144,20 @@ export function initGlobalSearch(): void {
if (isTextInput(target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
input.focus();
input.select();
openPalette(input, overlay);
});
// Cmd+K / Ctrl+K — power-user trigger. Unlike "/", we DON'T skip when the
// user is in a text input: the explicit Cmd+K combo signals intent to open
// the palette regardless of focus context. preventDefault + stopPropagation
// suppress browser-native Ctrl+K behavior (Firefox: focus URL-bar search
// submenu).
document.addEventListener("keydown", (e) => {
const isCmdK = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "k";
if (!isCmdK) return;
e.preventDefault();
e.stopPropagation();
openPalette(input, overlay);
});
// Clicking outside closes; ignore clicks inside the input or overlay
@@ -117,12 +168,28 @@ export function initGlobalSearch(): void {
closeOverlay(overlay);
});
// Language switch — re-render group headers etc. without re-fetching.
// Language switch — re-render group headers and action labels without
// re-fetching.
onLangChange(() => {
if (currentResponse) render(currentResponse, input.value.trim(), overlay);
if (overlay.style.display === "block") {
renderPalette(currentQuery, currentResponse, overlay);
}
});
}
function openPalette(input: HTMLInputElement, overlay: HTMLElement): void {
input.focus();
input.select();
const q = input.value.trim();
currentQuery = q;
if (q.length < MIN_QUERY) {
currentResponse = null;
renderPalette(q, null, overlay);
} else {
runSearch(q, overlay);
}
}
function isTextInput(el: HTMLElement): boolean {
const tag = el.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
@@ -139,65 +206,133 @@ async function runSearch(query: string, overlay: HTMLElement): Promise<void> {
{ signal: inFlight.signal, credentials: "same-origin" },
);
if (!res.ok) {
closeOverlay(overlay);
// Even on a backend hiccup we still render the action catalog filtered
// by the query — the palette stays useful.
currentResponse = null;
renderPalette(query, null, overlay);
return;
}
const data: SearchResponse = await res.json();
currentResponse = data;
render(data, query, overlay);
renderPalette(query, data, overlay);
} catch (err) {
if ((err as Error).name === "AbortError") return;
closeOverlay(overlay);
currentResponse = null;
renderPalette(query, null, overlay);
}
}
function render(data: SearchResponse, query: string, overlay: HTMLElement): void {
function renderPalette(query: string, data: SearchResponse | null, overlay: HTMLElement): void {
flatResults = [];
activeIndex = -1;
const sections: string[] = [];
for (const group of GROUP_ORDER) {
const items = data[group.key] as SearchResult[];
if (!items || items.length === 0) continue;
const header = `<div class="search-group-header">${esc(t(group.i18n) || group.fallback)}</div>`;
const lis = items.map((r) => {
const idx = flatResults.length;
flatResults.push(r);
const subtitle = r.subtitle
? `<span class="search-result-subtitle">${highlight(r.subtitle, query)}</span>`
: "";
return (
`<a class="search-result" role="option" data-index="${idx}" href="${encodeURI(r.url)}" data-type="${r.type}">` +
`<span class="search-result-icon">${iconFor(r.type)}</span>` +
`<span class="search-result-body">` +
`<span class="search-result-title">${highlight(r.title, query)}</span>` +
subtitle +
`</span>` +
`</a>`
);
}).join("");
sections.push(`<div class="search-group">${header}${lis}</div>`);
// Actions section — always first, always rendered (even on empty query).
const actions = filterActions(query, t);
if (actions.length > 0) {
sections.push(renderActionsSection(actions, query));
}
// Entity section — only when we have data (i.e., the user typed something
// and the fetch succeeded).
if (data) {
for (const group of GROUP_ORDER) {
const items = data[group.key] as SearchResult[];
if (!items || items.length === 0) continue;
sections.push(renderEntityGroup(group, items, query));
}
}
if (sections.length === 0) {
overlay.innerHTML = `<div class="search-empty">${esc(t("search.no_results") || "Keine Ergebnisse")}</div>`;
} else {
overlay.innerHTML = sections.join("");
// Click handlers — links already navigate, but close the overlay on
// mousedown so the click lands cleanly even when focus was elsewhere.
overlay.querySelectorAll<HTMLAnchorElement>(".search-result").forEach((el) => {
el.addEventListener("mousedown", (ev) => {
// Let middle-click / cmd-click open in new tab without interfering.
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey) return;
});
el.addEventListener("mouseenter", () => {
const idx = parseInt(el.dataset.index ?? "-1", 10);
if (!isNaN(idx)) setActive(idx, overlay);
});
el.addEventListener("click", () => closeOverlay(overlay));
});
overlay.innerHTML = sections.join("") + renderFooter();
bindResultClicks(overlay);
}
overlay.style.display = "block";
// Auto-select the first item so ↵ works without an arrow press first.
if (flatResults.length > 0) {
setActive(0, overlay);
}
}
function renderActionsSection(actions: PaletteAction[], query: string): string {
const header = `<div class="search-group-header">${esc(t("palette.section.actions") || "Aktionen")}</div>`;
const lis = actions.map((action) => {
const idx = flatResults.length;
flatResults.push({ kind: "action", action });
const label = labelFor(action, t);
return (
`<button type="button" class="search-result palette-action" role="option" data-index="${idx}" data-action-id="${esc(action.id)}">` +
`<span class="search-result-icon">${iconForAction(action.iconKey)}</span>` +
`<span class="search-result-body">` +
`<span class="search-result-title">${highlight(label, query)}</span>` +
`</span>` +
`</button>`
);
}).join("");
return `<div class="search-group search-group-actions">${header}${lis}</div>`;
}
function renderEntityGroup(
group: { key: keyof SearchResponse; i18n: string; fallback: string },
items: SearchResult[],
query: string,
): string {
const header = `<div class="search-group-header">${esc(t(group.i18n) || group.fallback)}</div>`;
const lis = items.map((r) => {
const idx = flatResults.length;
flatResults.push({ kind: "entity", result: r });
const subtitle = r.subtitle
? `<span class="search-result-subtitle">${highlight(r.subtitle, query)}</span>`
: "";
return (
`<a class="search-result" role="option" data-index="${idx}" href="${encodeURI(r.url)}" data-type="${r.type}">` +
`<span class="search-result-icon">${iconFor(r.type)}</span>` +
`<span class="search-result-body">` +
`<span class="search-result-title">${highlight(r.title, query)}</span>` +
subtitle +
`</span>` +
`</a>`
);
}).join("");
return `<div class="search-group">${header}${lis}</div>`;
}
function renderFooter(): string {
return (
`<div class="palette-footer">` +
`<span><kbd>&uarr;&darr;</kbd> <span>${esc(t("palette.footer.navigate") || "Navigieren")}</span></span>` +
`<span><kbd>&crarr;</kbd> <span>${esc(t("palette.footer.open") || "Öffnen")}</span></span>` +
`<span><kbd>Esc</kbd> <span>${esc(t("palette.footer.close") || "Schließen")}</span></span>` +
`</div>`
);
}
function bindResultClicks(overlay: HTMLElement): void {
overlay.querySelectorAll<HTMLElement>(".search-result").forEach((el) => {
el.addEventListener("mouseenter", () => {
const idx = parseInt(el.dataset.index ?? "-1", 10);
if (!isNaN(idx)) setActive(idx, overlay);
});
el.addEventListener("click", (ev) => {
// Action buttons have no href — dispatch via runAction. Anchor entity
// results navigate natively, so we just close the overlay (after the
// browser handles the click).
if (el.classList.contains("palette-action")) {
ev.preventDefault();
const id = el.dataset.actionId;
if (id) {
closeOverlay(overlay);
runAction(id);
}
} else {
closeOverlay(overlay);
}
});
});
}
function moveActive(delta: number, overlay: HTMLElement): void {
@@ -222,14 +357,18 @@ function setActive(idx: number, overlay: HTMLElement): void {
function openActive(): void {
if (activeIndex < 0 || activeIndex >= flatResults.length) {
// No selection — if there is exactly one result, open it; otherwise no-op.
if (flatResults.length === 1) {
window.location.href = flatResults[0].url;
}
if (flatResults.length === 1) dispatchItem(flatResults[0]);
return;
}
const result = flatResults[activeIndex];
window.location.href = result.url;
dispatchItem(flatResults[activeIndex]);
}
function dispatchItem(item: ActiveItem): void {
if (item.kind === "action") {
runAction(item.action.id);
} else {
window.location.href = item.result.url;
}
}
function closeOverlay(overlay: HTMLElement): void {
@@ -238,6 +377,7 @@ function closeOverlay(overlay: HTMLElement): void {
flatResults = [];
activeIndex = -1;
currentResponse = null;
currentQuery = "";
if (inFlight) {
inFlight.abort();
inFlight = null;
@@ -266,10 +406,8 @@ function esc(s: string): string {
return d.innerHTML;
}
// iconFor returns a tiny inline SVG for each result type. Matches the sidebar
// icon language — stroke-based, 1.5 width, currentColor. Kept minimal and
// readable so future additions can mirror the pattern without consulting a
// design system doc.
// iconFor returns a tiny inline SVG for each entity result type. Matches the
// sidebar icon language — stroke-based, 1.5 width, currentColor.
function iconFor(type: ResultType): string {
const attrs = 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"';
switch (type) {

View File

@@ -90,7 +90,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
placeholder="Suchen..."
data-i18n-placeholder="search.placeholder"
aria-label="Suche" />
<kbd className="sidebar-search-kbd" aria-hidden="true">/</kbd>
<kbd className="sidebar-search-kbd" aria-hidden="true" title="/ oder Ctrl/Cmd+K">/</kbd>
</div>
<div className="search-overlay" id="global-search-overlay"
role="listbox" aria-label="Suchergebnisse"

View File

@@ -5784,6 +5784,56 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.85rem;
}
/* Command palette (t-paliad-044) — actions render as buttons styled like
entity results, footer carries kbd hints. */
.palette-action {
width: 100%;
background: transparent;
border: 0;
border-left: 3px solid transparent;
text-align: left;
font: inherit;
}
.palette-action:hover,
.palette-action.active {
background: rgba(0, 0, 0, 0.04);
border-left-color: var(--color-accent);
}
.search-group-actions .search-result-icon {
color: var(--color-accent);
}
.palette-footer {
display: flex;
gap: 1rem;
padding: 0.5rem 0.85rem;
margin-top: 0.25rem;
border-top: 1px solid var(--color-border);
font-size: 0.72rem;
color: var(--color-text-muted);
}
.palette-footer kbd {
display: inline-block;
padding: 0.05rem 0.35rem;
border: 1px solid var(--color-border);
border-radius: 3px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.7rem;
line-height: 1;
color: var(--color-text);
background: var(--color-surface);
margin-right: 0.25rem;
}
@media (max-width: 480px) {
.palette-footer {
display: none;
}
}
/* Mobile: full-width overlay below the hamburger-triggered drawer. When the
sidebar is closed on mobile the search input is hidden inside the drawer;
the overlay is still fixed-positioned so a focus-via-shortcut (no "/" on