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:
@@ -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",
|
||||
|
||||
202
frontend/src/client/palette-actions.ts
Normal file
202
frontend/src/client/palette-actions.ts
Normal 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 "";
|
||||
}
|
||||
@@ -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>↑↓</kbd> <span>${esc(t("palette.footer.navigate") || "Navigieren")}</span></span>` +
|
||||
`<span><kbd>↵</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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user