Skip to content

Commit 6796e1c

Browse files
authored
feat: add command palette and global keyboard shortcuts (#19)
1 parent 43f7b0e commit 6796e1c

32 files changed

Lines changed: 2189 additions & 31 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ jobs:
2323
- uses: actions/checkout@v6
2424
with:
2525
# Needed so commitlint can walk the PR's commit range.
26-
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
26+
# Quoted because `&& 0 || 1` short-circuits past the falsy 0 and always returns 1.
27+
fetch-depth: ${{ github.event_name == 'pull_request' && '0' || '1' }}
2728

2829
- uses: pnpm/action-setup@v6
2930

.github/workflows/pr-title.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ on:
55
types: [opened, edited, synchronize, reopened]
66

77
permissions:
8-
pull-requests: read
8+
# `pull-requests: write` is needed because `wip: true` makes the action
9+
# set the PR check to "pending" while the title contains [WIP].
10+
# See: https://github.com/amannn/action-semantic-pull-request#wip
11+
pull-requests: write
912

1013
jobs:
1114
validate:

apps/web/src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { buildAttributionHtml } from "@openmapx/core/server";
22
import { Suspense } from "react";
3+
import { GlobalKeybindings } from "@/components/command-palette/GlobalKeybindings";
34
import { ElevationHoverProvider } from "@/components/elevation/ElevationHoverContext";
45
import { CategoryResultMarkers } from "@/components/map/CategoryResultMarkers";
56
import { DataSourceDetailBridge } from "@/components/map/DataSourceDetailBridge";
@@ -68,6 +69,7 @@ export default function HomePage() {
6869
const terrainTileUrl = getTerrainTileUrl();
6970
return (
7071
<MapProvider>
72+
<GlobalKeybindings />
7173
<ElevationHoverProvider>
7274
<div className="relative w-full h-dvh overflow-hidden">
7375
<MapCanvas />
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"use client";
2+
3+
import Dialog from "@mui/material/Dialog";
4+
import { useTheme } from "@mui/material/styles";
5+
import Typography from "@mui/material/Typography";
6+
import useMediaQuery from "@mui/material/useMediaQuery";
7+
import {
8+
type Command,
9+
type CommandGroup,
10+
getPlatform,
11+
SCORE_CUTOFF,
12+
scoreCommand,
13+
useCommandPaletteStore,
14+
useSearchStore,
15+
} from "@openmapx/core";
16+
import { useTranslations } from "next-intl";
17+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18+
import { CommandPaletteFooter } from "./CommandPaletteFooter";
19+
import { CommandPaletteInput } from "./CommandPaletteInput";
20+
import { buildDefaultCommandRows, CommandPaletteList } from "./CommandPaletteList";
21+
import { COMMAND_PALETTE_LISTBOX_ID, SEARCH_INPUT_ID } from "./constants";
22+
23+
interface Props {
24+
/** Command list — provided by `GlobalKeybindings` so the palette, the
25+
* shortcuts dialog, and the global listener all share a single instance. */
26+
commands: Command[];
27+
}
28+
29+
// Synthetic id (double-underscore prefix) so it can't collide with any real
30+
// command id in the `search` group.
31+
const SEARCH_FALLBACK_ID = "__search-fallback__";
32+
33+
export function CommandPalette({ commands }: Props) {
34+
const theme = useTheme();
35+
const isXs = useMediaQuery(theme.breakpoints.down("sm"));
36+
const t = useTranslations("commandPalette");
37+
38+
const isOpen = useCommandPaletteStore((s) => s.isOpen);
39+
const close = useCommandPaletteStore((s) => s.close);
40+
const query = useCommandPaletteStore((s) => s.query);
41+
const setQuery = useCommandPaletteStore((s) => s.setQuery);
42+
43+
const setSearchQuery = useSearchStore((s) => s.setQuery);
44+
45+
const inputRef = useRef<HTMLInputElement | null>(null);
46+
const [highlight, setHighlight] = useState(0);
47+
// Groups the user has expanded via a "+N more" row in this open session.
48+
// Reset on each palette open so reopening starts collapsed.
49+
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<CommandGroup>>(() => new Set());
50+
51+
const handleQueryChange = useCallback(
52+
(nextQuery: string) => {
53+
setQuery(nextQuery);
54+
setHighlight(0);
55+
},
56+
[setQuery],
57+
);
58+
59+
const handleExpandGroup = useCallback((group: CommandGroup) => {
60+
setExpandedGroups((prev) => {
61+
if (prev.has(group)) return prev;
62+
const next = new Set(prev);
63+
next.add(group);
64+
return next;
65+
});
66+
}, []);
67+
68+
// Hand-off to SearchBar — runs the user's query through the regular search flow.
69+
const handleSearchOnMap = useCallback(() => {
70+
const q = query.trim();
71+
close();
72+
if (!q) return;
73+
requestAnimationFrame(() => {
74+
setSearchQuery(q);
75+
const el = document.getElementById(SEARCH_INPUT_ID) as HTMLInputElement | null;
76+
el?.focus();
77+
});
78+
}, [close, query, setSearchQuery]);
79+
80+
// Ranked filtered list when query is present, otherwise the raw command list.
81+
// When filtering, the synthetic "Search '<q>' on map" row is always appended so
82+
// it can be navigated by ↑↓ alongside real matches.
83+
const ranked = useMemo<Command[] | null>(() => {
84+
const q = query.trim();
85+
if (!q) return null;
86+
const matches = commands
87+
.map((c) => ({ c, s: scoreCommand(q, c) }))
88+
.filter((x) => x.s >= SCORE_CUTOFF)
89+
.sort((a, b) => b.s - a.s)
90+
.map((x) => x.c);
91+
const fallback: Command = {
92+
id: SEARCH_FALLBACK_ID,
93+
group: "search",
94+
label: t("searchOnMap", { query: q }),
95+
iconKey: "search",
96+
run: handleSearchOnMap,
97+
};
98+
return [...matches, fallback];
99+
}, [commands, query, t, handleSearchOnMap]);
100+
101+
const defaultRows = useMemo(
102+
() =>
103+
buildDefaultCommandRows(commands, {
104+
expandedGroups,
105+
onExpandGroup: handleExpandGroup,
106+
t: (key, values) => t(key, values),
107+
}),
108+
[commands, expandedGroups, handleExpandGroup, t],
109+
);
110+
const visible = useMemo(() => ranked ?? defaultRows, [ranked, defaultRows]);
111+
const noRealMatches = ranked !== null && ranked.length === 1; // only the fallback
112+
113+
// Reset state on open
114+
useEffect(() => {
115+
if (isOpen) {
116+
setHighlight(0);
117+
setExpandedGroups(new Set());
118+
// Auto-select existing text on re-open
119+
requestAnimationFrame(() => inputRef.current?.select());
120+
}
121+
}, [isOpen]);
122+
123+
// Keep the highlighted row scrolled into view as the user arrow-keys past the
124+
// visible area.
125+
useEffect(() => {
126+
const cmd = visible[highlight];
127+
if (!cmd) return;
128+
const el = document.getElementById(`command-row-${cmd.id}`);
129+
el?.scrollIntoView({ block: "nearest" });
130+
}, [highlight, visible]);
131+
132+
const runCommand = useCallback(
133+
(cmd: Command, event?: { metaKey?: boolean; ctrlKey?: boolean }) => {
134+
// Platform-aware "keep open" modifier so Ctrl+Enter doesn't trigger
135+
// it on macOS (where the user expects ⌘+Enter only).
136+
const isMac = getPlatform() === "mac";
137+
const keepOpen = isMac ? !!event?.metaKey : !!event?.ctrlKey;
138+
try {
139+
const result = cmd.run();
140+
if (!keepOpen && result !== false) close();
141+
} catch (e) {
142+
console.error(`[command-palette] '${cmd.id}' failed:`, e);
143+
}
144+
},
145+
[close],
146+
);
147+
148+
const handleKeyDown = useCallback(
149+
(e: React.KeyboardEvent<HTMLInputElement>) => {
150+
if (e.key === "ArrowDown") {
151+
e.preventDefault();
152+
setHighlight((h) => (visible.length === 0 ? 0 : (h + 1) % visible.length));
153+
return;
154+
}
155+
if (e.key === "ArrowUp") {
156+
e.preventDefault();
157+
setHighlight((h) => (visible.length === 0 ? 0 : (h - 1 + visible.length) % visible.length));
158+
return;
159+
}
160+
if (e.key === "Enter") {
161+
e.preventDefault();
162+
const cmd = visible[highlight];
163+
if (cmd) {
164+
runCommand(cmd, { metaKey: e.metaKey, ctrlKey: e.ctrlKey });
165+
}
166+
return;
167+
}
168+
// Escape is handled by the global keybindings listener.
169+
},
170+
[visible, highlight, runCommand],
171+
);
172+
173+
const selected = visible[highlight] ?? null;
174+
const selectedDomId = selected ? `command-row-${selected.id}` : null;
175+
176+
return (
177+
<Dialog
178+
open={isOpen}
179+
onClose={close}
180+
fullScreen={isXs}
181+
maxWidth="sm"
182+
fullWidth
183+
slotProps={{
184+
paper: {
185+
sx: {
186+
position: (isXs ? undefined : "absolute") as "absolute" | undefined,
187+
top: isXs ? undefined : 80,
188+
m: isXs ? 0 : 2,
189+
borderRadius: isXs ? 0 : 2,
190+
maxHeight: isXs ? "100dvh" : "70dvh",
191+
display: "flex",
192+
flexDirection: "column",
193+
overflow: "hidden",
194+
},
195+
},
196+
backdrop: {
197+
sx: { backgroundColor: "rgba(0,0,0,0.32)" },
198+
},
199+
}}
200+
>
201+
<CommandPaletteInput
202+
ref={inputRef}
203+
query={query}
204+
onQueryChange={handleQueryChange}
205+
onClose={close}
206+
onKeyDown={handleKeyDown}
207+
activeDescendantId={selectedDomId}
208+
/>
209+
<div style={{ overflowY: "auto", flex: 1 }}>
210+
{noRealMatches && (
211+
<Typography sx={{ px: 2, py: 1.5, color: "text.secondary", fontSize: 14 }}>
212+
{t("noResults")}
213+
</Typography>
214+
)}
215+
<CommandPaletteList
216+
defaultRows={defaultRows}
217+
rankedOverride={ranked}
218+
selectedId={selected?.id ?? null}
219+
listboxId={COMMAND_PALETTE_LISTBOX_ID}
220+
onRun={(cmd, e) => {
221+
const evt = e as unknown as { metaKey?: boolean; ctrlKey?: boolean };
222+
runCommand(cmd, evt);
223+
}}
224+
/>
225+
</div>
226+
<CommandPaletteFooter />
227+
</Dialog>
228+
);
229+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import Box from "@mui/material/Box";
4+
import { useTheme } from "@mui/material/styles";
5+
import Typography from "@mui/material/Typography";
6+
import useMediaQuery from "@mui/material/useMediaQuery";
7+
import { getPlatform } from "@openmapx/core";
8+
import { useTranslations } from "next-intl";
9+
10+
const KBD_SX = {
11+
fontFamily: "monospace",
12+
fontSize: 11,
13+
px: 0.5,
14+
py: 0.1,
15+
border: 1,
16+
borderColor: "divider",
17+
borderRadius: 0.5,
18+
color: "text.secondary",
19+
} as const;
20+
21+
export function CommandPaletteFooter() {
22+
const t = useTranslations("commandPalette");
23+
const theme = useTheme();
24+
const isXs = useMediaQuery(theme.breakpoints.down("sm"));
25+
if (isXs) return null;
26+
27+
const modKey = getPlatform() === "mac" ? "⌘" : "Ctrl";
28+
29+
return (
30+
<Box
31+
sx={{
32+
display: "flex",
33+
alignItems: "center",
34+
gap: 2,
35+
px: 2,
36+
py: 0.75,
37+
borderTop: 1,
38+
borderColor: "divider",
39+
bgcolor: "background.default",
40+
}}
41+
>
42+
<FooterHint kbd={["↑", "↓"]} label={t("footerNavigate")} />
43+
<FooterHint kbd={["↵"]} label={t("footerSelect")} />
44+
<FooterHint kbd={[modKey, "↵"]} label={t("footerSelectAndKeep")} />
45+
<FooterHint kbd={["esc"]} label={t("footerClose")} />
46+
</Box>
47+
);
48+
}
49+
50+
function FooterHint({ kbd, label }: { kbd: string[]; label: string }) {
51+
return (
52+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
53+
{kbd.map((k) => (
54+
<Typography key={k} component="kbd" sx={KBD_SX}>
55+
{k}
56+
</Typography>
57+
))}
58+
<Typography variant="caption" color="text.secondary">
59+
{label}
60+
</Typography>
61+
</Box>
62+
);
63+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"use client";
2+
3+
import CloseIcon from "@mui/icons-material/Close";
4+
import SearchIcon from "@mui/icons-material/Search";
5+
import Box from "@mui/material/Box";
6+
import IconButton from "@mui/material/IconButton";
7+
import InputBase from "@mui/material/InputBase";
8+
import { useTranslations } from "next-intl";
9+
import { forwardRef, type KeyboardEvent } from "react";
10+
import { COMMAND_PALETTE_LISTBOX_ID } from "./constants";
11+
12+
interface Props {
13+
query: string;
14+
onQueryChange: (q: string) => void;
15+
onClose: () => void;
16+
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
17+
activeDescendantId: string | null;
18+
}
19+
20+
export const CommandPaletteInput = forwardRef<HTMLInputElement, Props>(function CommandPaletteInput(
21+
{ query, onQueryChange, onClose, onKeyDown, activeDescendantId },
22+
ref,
23+
) {
24+
const t = useTranslations("commandPalette");
25+
const tCommon = useTranslations("common");
26+
27+
return (
28+
<Box
29+
sx={{
30+
display: "flex",
31+
alignItems: "center",
32+
gap: 1,
33+
px: 2,
34+
py: 1.25,
35+
borderBottom: 1,
36+
borderColor: "divider",
37+
}}
38+
>
39+
<SearchIcon color="action" fontSize="small" />
40+
<InputBase
41+
inputRef={ref}
42+
value={query}
43+
onChange={(e) => onQueryChange(e.target.value)}
44+
onKeyDown={onKeyDown}
45+
placeholder={t("placeholder")}
46+
autoFocus
47+
fullWidth
48+
inputProps={{
49+
role: "combobox",
50+
"aria-label": t("inputAriaLabel"),
51+
"aria-expanded": true,
52+
"aria-controls": COMMAND_PALETTE_LISTBOX_ID,
53+
"aria-activedescendant": activeDescendantId ?? undefined,
54+
"aria-autocomplete": "list",
55+
}}
56+
sx={{ fontSize: 16 }}
57+
/>
58+
<IconButton onClick={onClose} aria-label={tCommon("close")} size="small">
59+
<CloseIcon fontSize="small" />
60+
</IconButton>
61+
</Box>
62+
);
63+
});

0 commit comments

Comments
 (0)