Skip to content
Open
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
Expand Up @@ -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;

Expand Down Expand Up @@ -147,7 +148,18 @@
<Popover.Root bind:open={active}>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} type="text" theme label={tooltipText}>
<Button
{...props}
type="text"
theme
label={tooltipText}
dataAttributes={{
"data-dashboard-shortcut":
type === "measure"
? DashboardShortcutAction.OpenMetricPicker
: DashboardShortcutAction.OpenDimensionPicker,
}}
>
<div class="flex items-center gap-x-0.5 px-1">
<strong>{buttonLabel}</strong>
<span class="transition-transform" class:-rotate-180={active}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import type { SearchableFilterSelectableGroup } from "@rilldata/web-common/components/searchable-filter-menu/SearchableFilterSelectableItem.ts";
import { isSimpleMeasure } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures.ts";
import { MetricsViewSpecDimensionType } from "@rilldata/web-common/runtime-client";
import { DashboardShortcutAction } from "@rilldata/web-common/features/dashboards/shortcuts/dashboard-shortcuts";

let {
expressionFilterManager,
Expand Down Expand Up @@ -81,6 +82,7 @@
<Tooltip distance={8} suppress={open}>
<button
{...props}
data-dashboard-shortcut={DashboardShortcutAction.OpenFilter}
class:addBorder
class:active={open}
aria-label={m.dashboard_add_filter_button()}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script lang="ts">
import * as Dialog from "@rilldata/web-common/components/dialog";
import Kbd from "@rilldata/web-common/components/Kbd.svelte";
import {
DASHBOARD_SHORTCUTS,
DashboardShortcutAction,
getDashboardShortcutAction,
performDashboardShortcut,
} from "./dashboard-shortcuts";

let open = false;

function handleKeydown(event: KeyboardEvent) {
const action = getDashboardShortcutAction(event);
if (!action || !performDashboardShortcut(action)) return;

event.preventDefault();
event.stopPropagation();
}
</script>

<svelte:window onkeydown={handleKeydown} />

<button
class="sr-only"
data-dashboard-shortcut={DashboardShortcutAction.ToggleHelp}
aria-label="Toggle keyboard shortcuts menu"
onclick={() => (open = !open)}
>
Keyboard shortcuts
</button>

<Dialog.Root bind:open>
<Dialog.Content class="max-w-md gap-5">
<Dialog.Header>
<Dialog.Title>Keyboard shortcuts</Dialog.Title>
<Dialog.Description>
Use these shortcuts while exploring a dashboard.
</Dialog.Description>
</Dialog.Header>

<dl class="grid grid-cols-[1fr_auto] items-center gap-x-6 gap-y-3 text-sm">
{#each DASHBOARD_SHORTCUTS as shortcut (shortcut.action)}
<dt class="text-fg-secondary">{shortcut.description}</dt>
<dd class="flex items-center justify-end gap-1">
{#each shortcut.keys as key, index (key)}
{#if index > 0}<span class="text-fg-muted">+</span>{/if}
<Kbd className="min-w-7 text-center">{key}</Kbd>
{/each}
</dd>
{/each}
</dl>
</Dialog.Content>
</Dialog.Root>
Original file line number Diff line number Diff line change
@@ -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);
});
});
133 changes: 133 additions & 0 deletions web-common/src/features/dashboards/shortcuts/dashboard-shortcuts.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(
`[data-dashboard-shortcut="${action}"]`,
);
if (!target) return false;

target.click();
return true;
}
2 changes: 2 additions & 0 deletions web-common/src/features/dashboards/workspace/Dashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -161,6 +162,7 @@
</script>

<ThemeProvider theme={$theme}>
<DashboardShortcuts />
<article
class="flex flex-col overflow-y-hidden bg-surface-background"
bind:clientWidth={exploreContainerWidth}
Expand Down
Loading