Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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"
import {
GripDotsIcon,
Expand Down Expand Up @@ -81,8 +83,46 @@ export function WidgetShell({
const [legendItems, setLegendItems] = useState<ChartLegendItem[]>([])
const legendSlot = useMemo(() => ({ setItems: setLegendItems }), [])

// "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 [focused, setFocused] = useState(false)

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 (
<Card className="group/card h-full flex flex-col">
<Card
className={cn(
"group/card h-full flex flex-col",
canExpand &&
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
// Make the card a tab stop only when it can expand, so keyboard users
// can focus it and use "F" the same way pointer users hover it.
tabIndex={canExpand ? 0 : undefined}
onMouseEnter={() => setHovered(true)}
Comment thread
pullfrog[bot] marked this conversation as resolved.
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)
}}
>
<CardHeader className="py-2.5 items-center">
<div className="flex min-w-0 items-center gap-2">
{isEditable && (
Expand Down
153 changes: 153 additions & 0 deletions apps/web/src/hooks/use-time-range-keyboard.ts
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 })
}
16 changes: 16 additions & 0 deletions apps/web/src/lib/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
14 changes: 13 additions & 1 deletion apps/web/src/routes/dashboards/$dashboardId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 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.


return <PageRefreshProvider timePreset={timePreset}>{children}</PageRefreshProvider>
}

Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<string, unknown>) => ({
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/routes/services/$serviceName.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
Expand Down
Loading