From 7cf6b2bcb197297c88e021c2bd6cff4c88430685 Mon Sep 17 00:00:00 2001 From: rohithreddykota Date: Thu, 10 Sep 2026 15:56:22 -0400 Subject: [PATCH] feat: add dashboard keyboard shortcuts --- .../menu/DashboardMetricsDraggableList.svelte | 14 +- .../filters/AddExpressionFilterButton.svelte | 2 + .../shortcuts/DashboardShortcuts.svelte | 54 +++++++ .../shortcuts/dashboard-shortcuts.spec.ts | 112 +++++++++++++++ .../shortcuts/dashboard-shortcuts.ts | 133 ++++++++++++++++++ .../dashboards/workspace/Dashboard.svelte | 2 + 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 web-common/src/features/dashboards/shortcuts/DashboardShortcuts.svelte create mode 100644 web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.spec.ts create mode 100644 web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.ts diff --git a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte index 6690012c67bb..0d21e3d66fa3 100644 --- a/web-common/src/components/menu/DashboardMetricsDraggableList.svelte +++ b/web-common/src/components/menu/DashboardMetricsDraggableList.svelte @@ -27,6 +27,7 @@ computeTagVisibility, type TagIndex, } from "./tag-utils"; + import { DashboardShortcutAction } from "@rilldata/web-common/features/dashboards/shortcuts/dashboard-shortcuts"; type SelectableItem = MetricsViewSpecMeasure | MetricsViewSpecDimension; @@ -147,7 +148,18 @@ {#snippet child({ props })} - + + + + + Keyboard shortcuts + + Use these shortcuts while exploring a dashboard. + + + +
+ {#each DASHBOARD_SHORTCUTS as shortcut (shortcut.action)} +
{shortcut.description}
+
+ {#each shortcut.keys as key, index (key)} + {#if index > 0}+{/if} + {key} + {/each} +
+ {/each} +
+
+
diff --git a/web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.spec.ts b/web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.spec.ts new file mode 100644 index 000000000000..192887a977ac --- /dev/null +++ b/web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import { + DASHBOARD_SHORTCUTS, + DashboardShortcutAction, + getDashboardShortcutAction, + performDashboardShortcut, +} from "./dashboard-shortcuts"; + +function keyboardEvent( + key: string, + options: KeyboardEventInit = {}, + target: EventTarget = document.body, +) { + const event = new KeyboardEvent("keydown", { key, ...options }); + Object.defineProperty(event, "target", { value: target }); + return event; +} + +describe("dashboard shortcuts", () => { + it("defines the dashboard menu shortcuts", () => { + expect(DASHBOARD_SHORTCUTS.slice(0, 4)).toEqual([ + { + action: DashboardShortcutAction.ToggleHelp, + keys: ["?"], + description: "Show/hide the keyboard-shortcuts menu", + }, + { + action: DashboardShortcutAction.OpenFilter, + keys: ["/"], + description: "Open the Filter menu", + }, + { + action: DashboardShortcutAction.OpenMetricPicker, + keys: [","], + description: "Open the metric picker", + }, + { + action: DashboardShortcutAction.OpenDimensionPicker, + keys: ["."], + description: "Open the dimension picker", + }, + ]); + }); + + it.each([ + ["?", DashboardShortcutAction.ToggleHelp], + ["/", DashboardShortcutAction.OpenFilter], + [",", DashboardShortcutAction.OpenMetricPicker], + [".", DashboardShortcutAction.OpenDimensionPicker], + ])("maps %s to %s", (key, action) => { + expect(getDashboardShortcutAction(keyboardEvent(key))).toBe(action); + }); + + it("ignores shortcuts while editing text", () => { + for (const target of [ + document.createElement("input"), + document.createElement("textarea"), + document.createElement("select"), + ]) { + expect(getDashboardShortcutAction(keyboardEvent("/", {}, target))).toBe( + undefined, + ); + } + + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + expect(getDashboardShortcutAction(keyboardEvent("/", {}, editable))).toBe( + undefined, + ); + }); + + it("ignores repeated and modified shortcuts", () => { + expect( + getDashboardShortcutAction(keyboardEvent("/", { repeat: true })), + ).toBe(undefined); + expect( + getDashboardShortcutAction(keyboardEvent("/", { metaKey: true })), + ).toBe(undefined); + expect( + getDashboardShortcutAction(keyboardEvent("/", { ctrlKey: true })), + ).toBe(undefined); + expect( + getDashboardShortcutAction(keyboardEvent("/", { altKey: true })), + ).toBe(undefined); + }); + + it("allows Shift for punctuation keys", () => { + expect( + getDashboardShortcutAction(keyboardEvent("?", { shiftKey: true })), + ).toBe(DashboardShortcutAction.ToggleHelp); + }); + + it("clicks the element registered for an action", () => { + const root = document.createElement("div"); + const target = document.createElement("button"); + const click = vi.fn(); + target.dataset.dashboardShortcut = DashboardShortcutAction.OpenFilter; + target.addEventListener("click", click); + root.appendChild(target); + + expect( + performDashboardShortcut(DashboardShortcutAction.OpenFilter, root), + ).toBe(true); + expect(click).toHaveBeenCalledOnce(); + expect( + performDashboardShortcut( + DashboardShortcutAction.OpenDimensionPicker, + root, + ), + ).toBe(false); + }); +}); diff --git a/web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.ts b/web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.ts new file mode 100644 index 000000000000..79500875b167 --- /dev/null +++ b/web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.ts @@ -0,0 +1,133 @@ +export enum DashboardShortcutAction { + ToggleHelp = "toggle-help", + OpenFilter = "open-filter", + OpenMetricPicker = "open-metric-picker", + OpenDimensionPicker = "open-dimension-picker", + InspectCell = "inspect-cell", + LockInspector = "lock-inspector", + ClearSelection = "clear-selection", + SelectAll = "select-all", + PanLeft = "pan-left", + PanRight = "pan-right", + Zoom = "zoom", + UndoZoom = "undo-zoom", + Explain = "explain", +} + +export interface DashboardShortcut { + action: DashboardShortcutAction; + keys: string[]; + description: string; +} + +export const DASHBOARD_SHORTCUTS: DashboardShortcut[] = [ + { + action: DashboardShortcutAction.ToggleHelp, + keys: ["?"], + description: "Show/hide the keyboard-shortcuts menu", + }, + { + action: DashboardShortcutAction.OpenFilter, + keys: ["/"], + description: "Open the Filter menu", + }, + { + action: DashboardShortcutAction.OpenMetricPicker, + keys: [","], + description: "Open the metric picker", + }, + { + action: DashboardShortcutAction.OpenDimensionPicker, + keys: ["."], + description: "Open the dimension picker", + }, + { + action: DashboardShortcutAction.InspectCell, + keys: ["Space"], + description: "Show/hide the cell inspector", + }, + { + action: DashboardShortcutAction.LockInspector, + keys: ["L"], + description: "Lock/unlock the cell inspector", + }, + { + action: DashboardShortcutAction.ClearSelection, + keys: ["Esc"], + description: "Close the inspector or clear a chart selection", + }, + { + action: DashboardShortcutAction.SelectAll, + keys: ["⌘/Ctrl", "A"], + description: "Select all dimension values", + }, + { + action: DashboardShortcutAction.PanLeft, + keys: ["←"], + description: "Pan the chart backward", + }, + { + action: DashboardShortcutAction.PanRight, + keys: ["→"], + description: "Pan the chart forward", + }, + { + action: DashboardShortcutAction.Zoom, + keys: ["Z"], + description: "Zoom into the selected range", + }, + { + action: DashboardShortcutAction.UndoZoom, + keys: ["⌘/Ctrl", "Z"], + description: "Undo chart zoom", + }, + { + action: DashboardShortcutAction.Explain, + keys: ["E"], + description: "Explain the selected range", + }, +]; + +const KEY_ACTIONS = new Map([ + ["?", DashboardShortcutAction.ToggleHelp], + ["/", DashboardShortcutAction.OpenFilter], + [",", DashboardShortcutAction.OpenMetricPicker], + [".", DashboardShortcutAction.OpenDimensionPicker], +]); + +function isEditableTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + (!!target.closest("input, textarea, select, [contenteditable]") || + (target instanceof HTMLElement && target.isContentEditable)) + ); +} + +export function getDashboardShortcutAction( + event: KeyboardEvent, +): DashboardShortcutAction | undefined { + if ( + event.repeat || + event.metaKey || + event.ctrlKey || + event.altKey || + isEditableTarget(event.target) + ) { + return undefined; + } + + return KEY_ACTIONS.get(event.key); +} + +export function performDashboardShortcut( + action: DashboardShortcutAction, + root: ParentNode = document, +): boolean { + const target = root.querySelector( + `[data-dashboard-shortcut="${action}"]`, + ); + if (!target) return false; + + target.click(); + return true; +} diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index 1c047daca0bd..b050ea331b37 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -31,6 +31,7 @@ import { useTimeControlStore } from "../time-controls/time-control-store"; import TimeDimensionDisplay from "../time-dimension-details/TimeDimensionDisplay.svelte"; import MetricsTimeSeriesCharts from "../time-series/MetricsTimeSeriesCharts.svelte"; + import DashboardShortcuts from "../shortcuts/DashboardShortcuts.svelte"; import { DEFAULT_TDD_CHART_HEIGHT, DEFAULT_TIMESERIES_WIDTH, @@ -161,6 +162,7 @@ +