feat(charts): keyboard navigation for time-series charts#106
Conversation
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 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No critical issues — two informational observations, nothing blocking.
Reviewed changes — keyboard pan/zoom of the selected time window across the dashboard, service-detail, and home pages, plus an F shortcut to expand the hovered chart. This is the keyboard-only slice; it stacks on #105 (base feat/chart-time-range-ux), which carries the expand modal and the isOverlayOpen plumbing.
- Add
useTimeRangeKeyboardControls— awindowcapture-phase keydown listener that reads the resolved absolute window, applies pan (←/→) or center-zoom (↑/↓) with Shift/Ctrl-Meta step modifiers, and writes an absolute range back viaonChange. Clamps into[now − 2y, now]with a 1-minute floor via a single-passclampToBand, and no-ops when already clamped. - Add
isOverlayOpen()tokeyboard.ts— broader thanisDialogOpen(), also matchingrole="menu"/role="listbox"[data-open]so page shortcuts defer to dropdown/select navigation. - Per-widget
Flistener inwidget-shell.tsx— attached only while the shell is hovered andcanExpand, opening the expand modal for the chart under the mouse. - Route wiring —
dashboards/$dashboardId.tsx(enabled whenresolvedTimeRange != null),services/$serviceName.tsx(enabled only on the overview tab), andindex.tsx(noenabledguard, since the home page always renders charts).
The pan/zoom math, the warehouse-string round-trip (no timezone drift through parseMs/formatForTinybird), and the disjoint-key handling between the two capture-phase listeners all check out. The two notes below are deliberate-design questions, not bugs.
ℹ️ Arrow keys stop scrolling the page on these three routes
The capture-phase listener preventDefault()s every arrow keypress it owns whenever no input is focused and no overlay is open, on all three routes. That removes arrow-key vertical scrolling for keyboard users on pages that can be long and scrollable (dashboards in particular). It is an intentional tradeoff for the pan/zoom feature, but worth a conscious call since it affects the whole page, not just the charts.
Technical details
# Arrow keys no longer scroll the page on keyboard-nav routes
## Affected sites
- `apps/web/src/hooks/use-time-range-keyboard.ts:155-156` — `preventDefault()`/`stopPropagation()` fire for any handled arrow key once the editable/overlay guards pass, regardless of whether the user intended to scroll.
## Required outcome
- Confirm losing arrow-key page scroll on the dashboard / service-detail / home routes is the intended UX. Page Up/Down and the mouse wheel still scroll, so this may be fine — but it is a global behavior change, not chart-scoped.
## Open questions for the human
- Should scroll be preserved when the pointer/focus is not over a chart region (e.g. only capture arrows while a chart is hovered, mirroring the `F` hover-scoping in `widget-shell.tsx`)? That would localize the keyboard takeover to the charts the feature is about.Claude Opus | 𝕏
| end: resolvedTimeRange?.endTime ?? "", | ||
| enabled: resolvedTimeRange != null, | ||
| onChange: ({ startTime, endTime }) => setTimeRange({ type: "absolute", startTime, endTime }), | ||
| }) |
There was a problem hiding this comment.
Keyboard pan/zoom stays active in dashboard edit mode here — it's gated only on resolvedTimeRange != null, with no mode === "view" check (unlike the service-detail wiring, which gates on the overview tab). While editing widget layout, arrow keys will pan the time window and the capture-phase preventDefault will swallow them before anything else. Benign today since react-grid-layout's keyboard a11y doesn't appear to be enabled, but worth a conscious decision on whether arrows should drive the time window while the user is positioning widgets.
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.|
|
||
| useEffect(() => { | ||
| if (!hovered || !canExpand) return | ||
| const handler = (e: KeyboardEvent) => { |
There was a problem hiding this comment.
Please use https://tanstack.com/hotkeys/latest/docs/overview for hotkey managment ratehr than this manual one
| <Card className="group/card h-full flex flex-col"> | ||
| <Card | ||
| className="group/card h-full flex flex-col" | ||
| onMouseEnter={() => setHovered(true)} |
There was a problem hiding this comment.
should use real focus states rather than this hovered state i think this is a bit hacky since this wont work with focus rn
| useEffect(() => { | ||
| if (!enabled) return | ||
|
|
||
| const handler = (e: KeyboardEvent) => { |
There was a problem hiding this comment.
same again use https://tanstack.com/hotkeys/latest/docs/overview

Keyboard control over the selected time window. Stacked on #105 (the expand modal that
Fopens, plus the sharedisOverlayOpenguard, live there) — review/merge #105 first; this PR's diff is keyboard-only.Keys (dashboard, service-detail, home)
Implementation
useTimeRangeKeyboardControls— awindowcapture-phase keydown listener that operates on the resolved absolute window and writes an absolute range back. ItpreventDefaults only the keys it handles, and bails when an editable element is focused or an overlay (dialog / menu / listbox viaisOverlayOpen) owns the keyboard — so it never hijacks menu/select/popover navigation.Fis handled per widget-shell (only the hovered chart responds).Notes
bun typecheckat baseline (web 23 pre-existing, ui 0); lint/format clean.🤖 Generated with Claude Code