From 458160b265cdfa520797d68f9acc947eeb0915d6 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Sat, 20 Jun 2026 21:53:47 +0200 Subject: [PATCH 1/2] feat(charts): keyboard navigation for time-series charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds keyboard control over the selected time window on the dashboard, service-detail, and home charts (a global capture-phase listener that defers to focused inputs and any open dialog/menu/listbox): - Left / Right — pan the window into the past / future (Right clamps at now) - Up / Down — zoom in / out around the window center (clamped to a min width and a ~2y lookback) - Shift — much larger step; Ctrl/Meta — much finer step - F — open the hovered chart in the fullscreen expand modal Stacked on the chart time-range UX PR (the expand modal F targets, plus the shared `isOverlayOpen` guard, live there). Co-Authored-By: Claude Opus 4.8 --- .../widgets/widget-shell.tsx | 34 +++- apps/web/src/hooks/use-time-range-keyboard.ts | 169 ++++++++++++++++++ apps/web/src/lib/keyboard.ts | 16 ++ .../src/routes/dashboards/$dashboardId.tsx | 14 +- apps/web/src/routes/index.tsx | 10 ++ apps/web/src/routes/services/$serviceName.tsx | 11 ++ 6 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/hooks/use-time-range-keyboard.ts diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx index 5ef8254e5..d024cde51 100644 --- a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx @@ -1,5 +1,6 @@ -import { useMemo, useState, type ReactNode } from "react" +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react" import { cn } from "@maple/ui/utils" +import { isEditableTarget, isOverlayOpen } from "@/lib/keyboard" import { ChartLegendSlotContext, type ChartLegendItem } from "@maple/ui/components/ui/chart" import { GripDotsIcon, @@ -81,8 +82,37 @@ export function WidgetShell({ const [legendItems, setLegendItems] = useState([]) const legendSlot = useMemo(() => ({ setItems: setLegendItems }), []) + // "F" opens the expand modal for the chart the mouse is currently over. The + // listener is owned by the hovered shell (attached on mouse-enter, removed on + // leave) so only that one widget responds — no shared hover registry needed. + const [hovered, setHovered] = useState(false) + const expandRef = useRef<() => void>(() => undefined) + expandRef.current = () => { + if (canExpand) setExpanded(true) + } + + useEffect(() => { + if (!hovered || !canExpand) return + const handler = (e: KeyboardEvent) => { + if (e.key !== "f" && e.key !== "F") return + if (e.metaKey || e.ctrlKey || e.altKey) return + if (isEditableTarget(e.target)) return + if (isEditableTarget(document.activeElement)) return + if (isOverlayOpen()) return + e.preventDefault() + e.stopPropagation() + expandRef.current() + } + window.addEventListener("keydown", handler, { capture: true }) + return () => window.removeEventListener("keydown", handler, { capture: true }) + }, [hovered, canExpand]) + return ( - + setHovered(true)} + onMouseLeave={() => setHovered(false)} + >
{isEditable && ( diff --git a/apps/web/src/hooks/use-time-range-keyboard.ts b/apps/web/src/hooks/use-time-range-keyboard.ts new file mode 100644 index 000000000..383c6c768 --- /dev/null +++ b/apps/web/src/hooks/use-time-range-keyboard.ts @@ -0,0 +1,169 @@ +import { useEffect, useRef } from "react" +import { isEditableTarget, isOverlayOpen } from "@/lib/keyboard" +import { formatForTinybird } from "@/lib/time-utils" +import { normalizeTimestampInput } from "@/lib/timezone-format" + +/** Minimum window width zoom-in can shrink to (1 minute). */ +const MIN_WINDOW_MS = 60 * 1000 +/** Earliest start zoom-out / left-pan may reach (~2 years before now). */ +const MAX_LOOKBACK_MS = 2 * 365 * 24 * 60 * 60 * 1000 + +/** + * Step multipliers. The base fraction is the slice of the current window + * width applied per keypress; Shift makes the action "significantly stronger", + * Control/Meta "significantly smaller". Pan and zoom each have their own base. + */ +const PAN_FRACTION = { base: 0.2, shift: 1.0, fine: 0.04 } as const +const ZOOM_FRACTION = { base: 0.2, shift: 0.5, fine: 0.04 } as const + +type Modifier = "base" | "shift" | "fine" + +function modifierOf(e: KeyboardEvent): Modifier { + // Control or Meta → fine. Shift → strong. (Shift loses to Ctrl/Meta if both + // are somehow held, which keeps fine-control predictable.) + if (e.ctrlKey || e.metaKey) return "fine" + if (e.shiftKey) return "shift" + return "base" +} + +function parseMs(warehouse: string): number { + return new Date(normalizeTimestampInput(warehouse)).getTime() +} + +interface ResolvedRange { + startMs: number + endMs: number +} + +/** + * Clamp a window into the allowed band `[now − MAX_LOOKBACK_MS, now]`, + * preserving its width when it fits. If the window is wider than the whole band + * (reachable when a custom range spans more than the lookback), it collapses to + * exactly the full band rather than letting one edge escape past `now` or before + * the floor. Done as a single pass so the two bounds can't leak past each other + * the way two independent `if` clamps could. + */ +function clampToBand(startMs: number, endMs: number): ResolvedRange { + const now = Date.now() + const earliest = now - MAX_LOOKBACK_MS + const width = endMs - startMs + if (width >= MAX_LOOKBACK_MS) return { startMs: earliest, endMs: now } + if (endMs > now) return { startMs: now - width, endMs: now } + if (startMs < earliest) return { startMs: earliest, endMs: earliest + width } + return { startMs, endMs } +} + +/** + * Pan/zoom the [start, end] window for one keypress, clamping so: + * - `end` never exceeds now, + * - `start` never precedes now − MAX_LOOKBACK_MS, + * - the width never falls below MIN_WINDOW_MS. + * Returns `null` when the action is a no-op (already clamped). + */ +function applyKey( + key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown", + mod: Modifier, + { startMs, endMs }: ResolvedRange, +): ResolvedRange | null { + const width = Math.max(MIN_WINDOW_MS, endMs - startMs) + + let nextStart: number + let nextEnd: number + + if (key === "ArrowLeft" || key === "ArrowRight") { + const delta = width * PAN_FRACTION[mod] * (key === "ArrowLeft" ? -1 : 1) + nextStart = startMs + delta + nextEnd = endMs + delta + } else { + const center = (startMs + endMs) / 2 + const fraction = ZOOM_FRACTION[mod] + // Up = zoom in (shrink), Down = zoom out (grow). + const nextWidth = + key === "ArrowUp" ? Math.max(MIN_WINDOW_MS, width * (1 - fraction)) : width * (1 + fraction) + const half = nextWidth / 2 + nextStart = center - half + nextEnd = center + half + } + + const clamped = clampToBand(nextStart, nextEnd) + nextStart = Math.round(clamped.startMs) + nextEnd = Math.round(clamped.endMs) + if (nextEnd - nextStart < MIN_WINDOW_MS) return null + if (nextStart === startMs && nextEnd === endMs) return null + return { startMs: nextStart, endMs: nextEnd } +} + +export interface UseTimeRangeKeyboardControls { + /** Resolved absolute start, warehouse format "YYYY-MM-DD HH:mm:ss" (UTC). */ + start: string + /** Resolved absolute end, warehouse format "YYYY-MM-DD HH:mm:ss" (UTC). */ + end: string + /** When false, the listener stays detached. */ + enabled?: boolean + /** Receives the new absolute window in warehouse format. */ + onChange: (range: { startTime: string; endTime: string }) => void +} + +/** + * Arrow-key pan/zoom for a page's selected time window. Operates on the + * resolved absolute range and always writes an absolute range back via + * `onChange`: + * - Left / Right → pan into the past / future (Right clamps at now). + * - Up / Down → zoom in / out around the window center (clamped). + * - Shift → much larger step; Ctrl/Meta → much finer step. + * + * The listener runs on `window` in the capture phase so arrow keys reach the + * page even when the browser/OS would otherwise act on them, and it + * `preventDefault()`/`stopPropagation()`s only the keys it handles. It bails + * out while an editable element is focused or a modal dialog owns the keyboard. + */ +export function useTimeRangeKeyboardControls({ + start, + end, + enabled = true, + onChange, +}: UseTimeRangeKeyboardControls): void { + // Read the latest range/handler from a ref so the capture listener stays + // attached across range changes instead of re-binding on every keypress. + const stateRef = useRef({ start, end, onChange }) + stateRef.current = { start, end, onChange } + + useEffect(() => { + if (!enabled) return + + const handler = (e: KeyboardEvent) => { + if (e.altKey) return + if ( + e.key !== "ArrowLeft" && + e.key !== "ArrowRight" && + e.key !== "ArrowUp" && + e.key !== "ArrowDown" + ) { + return + } + if (isEditableTarget(e.target)) return + if (isEditableTarget(document.activeElement)) return + if (isOverlayOpen()) return + + const { start: s, end: en, onChange: emit } = stateRef.current + const startMs = parseMs(s) + const endMs = parseMs(en) + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) return + + // We own this key: stop the browser/OS (and capture-phase siblings) + // from acting on it before we apply the pan/zoom. + e.preventDefault() + e.stopPropagation() + + const next = applyKey(e.key, modifierOf(e), { startMs, endMs }) + if (!next) return + emit({ + startTime: formatForTinybird(new Date(next.startMs)), + endTime: formatForTinybird(new Date(next.endMs)), + }) + } + + window.addEventListener("keydown", handler, { capture: true }) + return () => window.removeEventListener("keydown", handler, { capture: true }) + }, [enabled]) +} diff --git a/apps/web/src/lib/keyboard.ts b/apps/web/src/lib/keyboard.ts index c1cf81c4a..71505b002 100644 --- a/apps/web/src/lib/keyboard.ts +++ b/apps/web/src/lib/keyboard.ts @@ -13,3 +13,19 @@ export function isDialogOpen(): boolean { // Base UI marks open popups with a bare `data-open` attribute (not Radix's `data-state="open"`). return document.querySelector('[role="dialog"][data-open], [role="alertdialog"][data-open]') !== null } + +/** + * True when ANY keyboard-owning overlay is open — a dialog, a dropdown menu, or + * a listbox (select / combobox). Page-level capture-phase shortcuts (arrow-key + * pan/zoom, the "F" expand key) must defer to these so the overlay's own + * arrow/typeahead navigation keeps working. Broader than `isDialogOpen` because + * Base UI menus render as `role="menu"` and selects/comboboxes as + * `role="listbox"`, neither of which is a dialog. + */ +export function isOverlayOpen(): boolean { + return ( + document.querySelector( + '[role="dialog"][data-open], [role="alertdialog"][data-open], [role="menu"][data-open], [role="listbox"][data-open]', + ) !== null + ) +} diff --git a/apps/web/src/routes/dashboards/$dashboardId.tsx b/apps/web/src/routes/dashboards/$dashboardId.tsx index 2f166505b..99b58ea7b 100644 --- a/apps/web/src/routes/dashboards/$dashboardId.tsx +++ b/apps/web/src/routes/dashboards/$dashboardId.tsx @@ -19,6 +19,7 @@ import { import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh-context" import type { WidgetMode } from "@/components/dashboard-builder/types" import { useDashboardStore } from "@/hooks/use-dashboard-store" +import { useTimeRangeKeyboardControls } from "@/hooks/use-time-range-keyboard" import { DashboardHistoryPanel, PreviewedCanvas } from "@/components/dashboard-builder/history" import { historyPanelOpenAtom, previewedVersionAtom } from "@/atoms/dashboard-history-atoms" import { useDashboardVersions } from "@/components/dashboard-builder/history/use-dashboard-history" @@ -39,9 +40,20 @@ export const Route = effectRoute(createFileRoute("/dashboards/$dashboardId"))({ function DashboardRefreshBridge({ children }: { children: ReactNode }) { const { - state: { timeRange }, + state: { timeRange, resolvedTimeRange }, + actions: { setTimeRange }, } = useDashboardTimeRange() const timePreset = timeRange.type === "relative" ? timeRange.value : undefined + + // Arrow-key pan/zoom over the resolved absolute window. Drag-to-zoom writes + // the range the same way (absolute), so this round-trips identically. + useTimeRangeKeyboardControls({ + start: resolvedTimeRange?.startTime ?? "", + end: resolvedTimeRange?.endTime ?? "", + enabled: resolvedTimeRange != null, + onChange: ({ startTime, endTime }) => setTimeRange({ type: "absolute", startTime, endTime }), + }) + return {children} } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a633fc8b3..689ef1351 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -26,6 +26,7 @@ import type { CustomChartTimeSeriesResponse } from "@/api/warehouse/custom-chart import type { ServiceDetailTimeSeriesPoint, ServicesFacetsResponse } from "@/api/warehouse/services" import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" import { applyTimeRangeSearch } from "@/components/time-range-picker/search" +import { useTimeRangeKeyboardControls } from "@/hooks/use-time-range-keyboard" import { zoomRangeToWarehouse } from "@/lib/time-utils" import { isClerkAuthEnabled } from "@/lib/services/common/auth-mode" @@ -165,6 +166,15 @@ function DashboardContent({ [navigate], ) + // Arrow-key pan/zoom over the resolved absolute window (Left/Right pan, + // Up/Down zoom, Shift/Ctrl modifiers). Writes an absolute range with + // `replace` so rapid presses don't stack history entries. + useTimeRangeKeyboardControls({ + start: effectiveStartTime, + end: effectiveEndTime, + onChange: ({ startTime, endTime }) => handleTimeChange({ startTime, endTime }, { replace: true }), + }) + const handleEnvironmentChange = (value: string | null) => { navigate({ search: (prev: Record) => ({ diff --git a/apps/web/src/routes/services/$serviceName.tsx b/apps/web/src/routes/services/$serviceName.tsx index 8f511f2b2..0df08e180 100644 --- a/apps/web/src/routes/services/$serviceName.tsx +++ b/apps/web/src/routes/services/$serviceName.tsx @@ -6,6 +6,7 @@ import { Schema } from "effect" import { DashboardLayout } from "@/components/layout/dashboard-layout" import { useEffectiveTimeRange } from "@/hooks/use-effective-time-range" +import { useTimeRangeKeyboardControls } from "@/hooks/use-time-range-keyboard" import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" import { MetricsGrid } from "@/components/dashboard/metrics-grid" import type { @@ -142,6 +143,16 @@ function ServiceDetailContent() { [navigate], ) + // Arrow-key pan/zoom over the resolved absolute window. Writes an absolute + // range (replace, so rapid presses don't stack history entries); only active + // on the Overview tab where the charts live. + useTimeRangeKeyboardControls({ + start: effectiveStartTime, + end: effectiveEndTime, + enabled: (search.tab ?? "overview") === "overview", + onChange: ({ startTime, endTime }) => handleTimeChange({ startTime, endTime }, { replace: true }), + }) + const activeTab: ServiceDetailTabValue = search.tab ?? "overview" const handleTabChange = (value: unknown) => { const next = value === "dependencies" ? "dependencies" : "overview" From 74f9b26aae410a6a17048deeb8e453b80a610d78 Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:53:34 +0000 Subject: [PATCH 2/2] refactor(charts): register keyboard nav via TanStack Hotkeys + real focus states - use-time-range-keyboard: replace the manual window capture-phase listener with useHotkeys, registering each arrow key per modifier (plain/Shift/Ctrl/Meta) since the matcher requires exact modifier state - widget-shell: replace the manual F listener with useHotkeys and trigger expand on keyboard focus-within as well as pointer hover; make expandable cards a tab stop with a focus-visible ring --- .../widgets/widget-shell.tsx | 58 +++++----- apps/web/src/hooks/use-time-range-keyboard.ts | 102 ++++++++---------- 2 files changed, 77 insertions(+), 83 deletions(-) diff --git a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx index d024cde51..1de7e98a7 100644 --- a/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx +++ b/apps/web/src/components/dashboard-builder/widgets/widget-shell.tsx @@ -1,4 +1,5 @@ -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react" +import { useMemo, useState, type ReactNode } from "react" +import { useHotkeys } from "@tanstack/react-hotkeys" import { cn } from "@maple/ui/utils" import { isEditableTarget, isOverlayOpen } from "@/lib/keyboard" import { ChartLegendSlotContext, type ChartLegendItem } from "@maple/ui/components/ui/chart" @@ -82,36 +83,45 @@ export function WidgetShell({ const [legendItems, setLegendItems] = useState([]) const legendSlot = useMemo(() => ({ setItems: setLegendItems }), []) - // "F" opens the expand modal for the chart the mouse is currently over. The - // listener is owned by the hovered shell (attached on mouse-enter, removed on - // leave) so only that one widget responds — no shared hover registry needed. + // "F" opens the expand modal for the chart the user is currently pointing at + // or has keyboard focus within. Pointer hover and DOM focus are tracked + // independently so the shortcut works for both mouse and keyboard users and + // neither input clobbers the other; the hotkey fires while either is active. const [hovered, setHovered] = useState(false) - const expandRef = useRef<() => void>(() => undefined) - expandRef.current = () => { - if (canExpand) setExpanded(true) - } + const [focused, setFocused] = useState(false) - useEffect(() => { - if (!hovered || !canExpand) return - const handler = (e: KeyboardEvent) => { - if (e.key !== "f" && e.key !== "F") return - if (e.metaKey || e.ctrlKey || e.altKey) return - if (isEditableTarget(e.target)) return - if (isEditableTarget(document.activeElement)) return - if (isOverlayOpen()) return - e.preventDefault() - e.stopPropagation() - expandRef.current() - } - window.addEventListener("keydown", handler, { capture: true }) - return () => window.removeEventListener("keydown", handler, { capture: true }) - }, [hovered, canExpand]) + useHotkeys( + [ + { + hotkey: "F", + callback: (e) => { + if (isEditableTarget(e.target) || isEditableTarget(document.activeElement)) return + if (isOverlayOpen()) return + if (canExpand) setExpanded(true) + }, + }, + ], + { enabled: (hovered || focused) && canExpand, stopPropagation: false }, + ) return ( setHovered(true)} onMouseLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={(e) => { + // Only blur when focus leaves the card entirely, not when it moves + // between children (where relatedTarget stays inside). + if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false) + }} >
diff --git a/apps/web/src/hooks/use-time-range-keyboard.ts b/apps/web/src/hooks/use-time-range-keyboard.ts index 383c6c768..3dd1c5a18 100644 --- a/apps/web/src/hooks/use-time-range-keyboard.ts +++ b/apps/web/src/hooks/use-time-range-keyboard.ts @@ -1,4 +1,5 @@ -import { useEffect, useRef } from "react" +import { useHotkeys, type UseHotkeyDefinition } from "@tanstack/react-hotkeys" +import { useRef } from "react" import { isEditableTarget, isOverlayOpen } from "@/lib/keyboard" import { formatForTinybird } from "@/lib/time-utils" import { normalizeTimestampInput } from "@/lib/timezone-format" @@ -17,14 +18,7 @@ const PAN_FRACTION = { base: 0.2, shift: 1.0, fine: 0.04 } as const const ZOOM_FRACTION = { base: 0.2, shift: 0.5, fine: 0.04 } as const type Modifier = "base" | "shift" | "fine" - -function modifierOf(e: KeyboardEvent): Modifier { - // Control or Meta → fine. Shift → strong. (Shift loses to Ctrl/Meta if both - // are somehow held, which keeps fine-control predictable.) - if (e.ctrlKey || e.metaKey) return "fine" - if (e.shiftKey) return "shift" - return "base" -} +type ArrowKey = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" function parseMs(warehouse: string): number { return new Date(normalizeTimestampInput(warehouse)).getTime() @@ -60,11 +54,7 @@ function clampToBand(startMs: number, endMs: number): ResolvedRange { * - the width never falls below MIN_WINDOW_MS. * Returns `null` when the action is a no-op (already clamped). */ -function applyKey( - key: "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown", - mod: Modifier, - { startMs, endMs }: ResolvedRange, -): ResolvedRange | null { +function applyKey(key: ArrowKey, mod: Modifier, { startMs, endMs }: ResolvedRange): ResolvedRange | null { const width = Math.max(MIN_WINDOW_MS, endMs - startMs) let nextStart: number @@ -98,7 +88,7 @@ export interface UseTimeRangeKeyboardControls { start: string /** Resolved absolute end, warehouse format "YYYY-MM-DD HH:mm:ss" (UTC). */ end: string - /** When false, the listener stays detached. */ + /** When false, the hotkeys stay registered but don't fire. */ enabled?: boolean /** Receives the new absolute window in warehouse format. */ onChange: (range: { startTime: string; endTime: string }) => void @@ -112,10 +102,11 @@ export interface UseTimeRangeKeyboardControls { * - Up / Down → zoom in / out around the window center (clamped). * - Shift → much larger step; Ctrl/Meta → much finer step. * - * The listener runs on `window` in the capture phase so arrow keys reach the - * page even when the browser/OS would otherwise act on them, and it - * `preventDefault()`/`stopPropagation()`s only the keys it handles. It bails - * out while an editable element is focused or a modal dialog owns the keyboard. + * Registered through TanStack Hotkeys. Because the matcher requires exact + * modifier state, each arrow key is registered three times — plain, `Shift+`, + * and `Ctrl+`/`Meta+` — mapping to the base / strong / fine step. The handler + * bails while an editable element is focused or any overlay (dialog, menu, + * listbox) owns the keyboard, so it never hijacks menu/select navigation. */ export function useTimeRangeKeyboardControls({ start, @@ -123,47 +114,40 @@ export function useTimeRangeKeyboardControls({ enabled = true, onChange, }: UseTimeRangeKeyboardControls): void { - // Read the latest range/handler from a ref so the capture listener stays - // attached across range changes instead of re-binding on every keypress. + // Read the latest range/handler from a ref so the callbacks stay stable + // across range changes (TanStack Hotkeys syncs callbacks each render anyway, + // but this keeps the closure reading current values). const stateRef = useRef({ start, end, onChange }) stateRef.current = { start, end, onChange } - useEffect(() => { - if (!enabled) return - - const handler = (e: KeyboardEvent) => { - if (e.altKey) return - if ( - e.key !== "ArrowLeft" && - e.key !== "ArrowRight" && - e.key !== "ArrowUp" && - e.key !== "ArrowDown" - ) { - return - } - if (isEditableTarget(e.target)) return - if (isEditableTarget(document.activeElement)) return - if (isOverlayOpen()) return - - const { start: s, end: en, onChange: emit } = stateRef.current - const startMs = parseMs(s) - const endMs = parseMs(en) - if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) return - - // We own this key: stop the browser/OS (and capture-phase siblings) - // from acting on it before we apply the pan/zoom. - e.preventDefault() - e.stopPropagation() - - const next = applyKey(e.key, modifierOf(e), { startMs, endMs }) - if (!next) return - emit({ - startTime: formatForTinybird(new Date(next.startMs)), - endTime: formatForTinybird(new Date(next.endMs)), - }) - } - - window.addEventListener("keydown", handler, { capture: true }) - return () => window.removeEventListener("keydown", handler, { capture: true }) - }, [enabled]) + const run = (key: ArrowKey, mod: Modifier, event: KeyboardEvent) => { + // Defer to editable fields and keyboard-owning overlays (menu / listbox / + // dialog) so their own arrow navigation keeps working. + if (isEditableTarget(event.target) || isEditableTarget(document.activeElement)) return + if (isOverlayOpen()) return + + const { start: s, end: en, onChange: emit } = stateRef.current + const startMs = parseMs(s) + const endMs = parseMs(en) + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) return + + const next = applyKey(key, mod, { startMs, endMs }) + if (!next) return + emit({ + startTime: formatForTinybird(new Date(next.startMs)), + endTime: formatForTinybird(new Date(next.endMs)), + }) + } + + const arrows: ArrowKey[] = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"] + const definitions: UseHotkeyDefinition[] = arrows.flatMap((key) => [ + { hotkey: { key }, callback: (e) => run(key, "base", e) }, + { hotkey: { key, shift: true }, callback: (e) => run(key, "shift", e) }, + // Ctrl and Meta both map to the fine step; register both since the + // matcher requires exact modifier state. + { hotkey: { key, ctrl: true }, callback: (e) => run(key, "fine", e) }, + { hotkey: { key, meta: true }, callback: (e) => run(key, "fine", e) }, + ]) + + useHotkeys(definitions, { enabled, ignoreInputs: true, stopPropagation: false }) }