-
Notifications
You must be signed in to change notification settings - Fork 96
feat(charts): keyboard navigation for time-series charts #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| 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" | ||
|
|
||
| /** 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" | ||
| type ArrowKey = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" | ||
|
|
||
| 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: ArrowKey, 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 hotkeys stay registered but don't fire. */ | ||
| 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. | ||
| * | ||
| * 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, | ||
| end, | ||
| enabled = true, | ||
| onChange, | ||
| }: UseTimeRangeKeyboardControls): void { | ||
| // 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 } | ||
|
|
||
| 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 }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }), | ||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keyboard pan/zoom stays active in dashboard edit mode here — it's gated only on Technical details# Keyboard nav active during dashboard edit mode
## Affected sites
- `apps/web/src/routes/dashboards/$dashboardId.tsx:50-55` — `useTimeRangeKeyboardControls` enabled whenever `resolvedTimeRange != null`; `mode` (`"edit"`/`"view"`, computed at line 89) is not consulted.
## Required outcome
- Decide whether arrow-key pan/zoom should be suppressed in edit mode. If so, thread `mode` into `DashboardRefreshBridge` (or read it where the bridge is rendered) and pass `enabled: mode === "view" && resolvedTimeRange != null`.
## Open questions for the human
- Is panning the window while editing widgets desirable (the charts still render the new range), or should editing fully own the keyboard? The service-detail route already takes the conservative path (`enabled` scoped to the overview tab), so scoping here would be consistent. |
||
|
|
||
| return <PageRefreshProvider timePreset={timePreset}>{children}</PageRefreshProvider> | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.