diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index a7d2378f765..e5c59000c8d 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -669,6 +669,10 @@ export interface FeatureToggles {
*/
timeRangePan?: boolean;
/**
+ * Enables new keyboard shortcuts for time range zoom operations
+ */
+ newTimeRangeZoomShortcuts?: boolean;
+ /**
* Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
* @default false
*/
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index c56f46a7193..28ff9e1809e 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -1099,6 +1099,13 @@ var (
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
+ {
+ Name: "newTimeRangeZoomShortcuts",
+ Description: "Enables new keyboard shortcuts for time range zoom operations",
+ Stage: FeatureStageExperimental,
+ FrontendOnly: true,
+ Owner: grafanaDatavizSquad,
+ },
{
Name: "azureMonitorDisableLogLimit",
Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.",
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 9465d6992c3..4ee6e57a9ed 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -152,6 +152,7 @@ pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
timeRangePan,experimental,@grafana/dataviz-squad,false,false,true
+newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json
index 2298b91d854..bb0e03295a0 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -2873,6 +2873,19 @@
"hideFromDocs": true
}
},
+ {
+ "metadata": {
+ "name": "newTimeRangeZoomShortcuts",
+ "resourceVersion": "1763646782694",
+ "creationTimestamp": "2025-11-20T13:53:02Z"
+ },
+ "spec": {
+ "description": "Enables new keyboard shortcuts for time range zoom operations",
+ "stage": "experimental",
+ "codeowner": "@grafana/dataviz-squad",
+ "frontend": true
+ }
+ },
{
"metadata": {
"name": "newVizSuggestions",
diff --git a/public/app/core/components/help/HelpModal.test.tsx b/public/app/core/components/help/HelpModal.test.tsx
index d343c5560b0..a410f1cd4d6 100644
--- a/public/app/core/components/help/HelpModal.test.tsx
+++ b/public/app/core/components/help/HelpModal.test.tsx
@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import { useAssistant } from '@grafana/assistant';
+import { config } from '@grafana/runtime';
import { useShortcuts } from './HelpModal';
@@ -148,4 +149,73 @@ describe('useShortcuts', () => {
const assistantShortcut = globalCategory!.shortcuts.find((shortcut) => shortcut.keys.includes('ctrl + .'));
expect(assistantShortcut).toBeDefined();
});
+
+ describe('time range zoom shortcuts with feature toggle', () => {
+ beforeEach(() => {
+ mockUseAssistant.mockReturnValue({
+ isAvailable: false,
+ openAssistant: jest.fn(),
+ closeAssistant: jest.fn(),
+ toggleAssistant: jest.fn(),
+ });
+ });
+
+ it('should show new zoom shortcuts when feature toggle is enabled', () => {
+ config.featureToggles.newTimeRangeZoomShortcuts = true;
+
+ const { result } = renderHook(() => useShortcuts());
+
+ const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
+
+ const zoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+'));
+ const zoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-'));
+
+ expect(zoomInShortcut).toBeDefined();
+ expect(zoomInShortcut!.isNew).toBe(true);
+ expect(zoomOutShortcut).toBeDefined();
+ expect(zoomOutShortcut!.isNew).toBe(true);
+ });
+
+ it('should show legacy t z shortcut when feature toggle is disabled', () => {
+ config.featureToggles.newTimeRangeZoomShortcuts = false;
+
+ const { result } = renderHook(() => useShortcuts());
+
+ const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
+
+ const legacyZoomShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('z'));
+ const newZoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+'));
+ const newZoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-'));
+
+ expect(legacyZoomShortcut).toBeDefined();
+ expect(newZoomInShortcut).toBeUndefined();
+ expect(newZoomOutShortcut).toBeUndefined();
+ });
+
+ it('should not show isNew badge on legacy shortcuts', () => {
+ config.featureToggles.newTimeRangeZoomShortcuts = false;
+
+ const { result } = renderHook(() => useShortcuts());
+
+ const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
+
+ const legacyZoomShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('z'));
+
+ expect(legacyZoomShortcut!.isNew).toBeUndefined();
+ });
+
+ it('should show isNew badge on new shortcuts when feature toggle is enabled', () => {
+ config.featureToggles.newTimeRangeZoomShortcuts = true;
+
+ const { result } = renderHook(() => useShortcuts());
+
+ const timeRangeCategory = result.current.find((cat) => cat.category.includes('Time range'));
+
+ const zoomInShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('+'));
+ const zoomOutShortcut = timeRangeCategory!.shortcuts.find((s) => s.keys.includes('t') && s.keys.includes('-'));
+
+ expect(zoomInShortcut!.isNew).toBe(true);
+ expect(zoomOutShortcut!.isNew).toBe(true);
+ });
+ });
});
diff --git a/public/app/core/components/help/HelpModal.tsx b/public/app/core/components/help/HelpModal.tsx
index 4017443b252..82f39fac28f 100644
--- a/public/app/core/components/help/HelpModal.tsx
+++ b/public/app/core/components/help/HelpModal.tsx
@@ -2,9 +2,10 @@ import { css } from '@emotion/css';
import { useMemo } from 'react';
import { useAssistant } from '@grafana/assistant';
-import { GrafanaTheme2 } from '@grafana/data';
+import { FeatureState, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
-import { Grid, Modal, useStyles2, Text } from '@grafana/ui';
+import { config } from '@grafana/runtime';
+import { Grid, Modal, useStyles2, Text, FeatureBadge } from '@grafana/ui';
import { getModKey } from 'app/core/utils/browser';
export interface HelpModalProps {
@@ -36,7 +37,7 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => {
- {shortcuts.map(({ keys, description }) => (
+ {shortcuts.map(({ keys, description, isNew }) => (
|
{keys.map((key) => (
@@ -44,9 +45,12 @@ export const HelpModal = ({ onDismiss }: HelpModalProps): JSX.Element => {
))}
|
-
- {description}
-
+
+
+ {description}
+
+ {isNew && }
+
|
))}
@@ -104,10 +108,25 @@ export const useShortcuts = () => {
{
category: t('help-modal.shortcuts-category.time-range', 'Time range'),
shortcuts: [
- {
- keys: ['t', 'z'],
- description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'),
- },
+ ...(config.featureToggles.newTimeRangeZoomShortcuts
+ ? [
+ {
+ keys: ['t', '+'],
+ description: t('help-modal.shortcuts-description.zoom-in-time-range', 'Zoom in time range'),
+ isNew: true,
+ },
+ {
+ keys: ['t', '-'],
+ description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'),
+ isNew: true,
+ },
+ ]
+ : [
+ {
+ keys: ['t', 'z'],
+ description: t('help-modal.shortcuts-description.zoom-out-time-range', 'Zoom out time range'),
+ },
+ ]),
{
keys: ['t', '←'],
description: t('help-modal.shortcuts-description.move-time-range-back', 'Move time range back'),
@@ -277,6 +296,12 @@ function getStyles(theme: GrafanaTheme2) {
whiteSpace: 'nowrap',
minWidth: 83, // To match column widths with the widest
}),
+ descriptionWrapper: css({
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(0.75),
+ flexWrap: 'nowrap',
+ }),
shortcutTableKey: css({
display: 'inline-block',
textAlign: 'center',
diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts
index 865f461ce84..ea232baffcc 100644
--- a/public/app/core/services/keybindingSrv.ts
+++ b/public/app/core/services/keybindingSrv.ts
@@ -1,6 +1,6 @@
import { toggleAssistant, isAssistantAvailable } from '@grafana/assistant';
import { LegacyGraphHoverClearEvent, SetPanelAttentionEvent, locationUtil } from '@grafana/data';
-import { LocationService } from '@grafana/runtime';
+import { LocationService, config } from '@grafana/runtime';
import { appEvents } from 'app/core/app_events';
import { getExploreUrl } from 'app/core/utils/explore';
import { toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils';
@@ -231,9 +231,19 @@ export class KeybindingSrv {
appEvents.publish(new AbsoluteTimeEvent({ updateUrl }));
});
- this.bind('t z', () => {
- appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
- });
+ if (config.featureToggles.newTimeRangeZoomShortcuts) {
+ this.bind('t +', () => {
+ appEvents.publish(new ZoomOutEvent({ scale: 0.5, updateUrl }));
+ });
+
+ this.bind('t -', () => {
+ appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
+ });
+ } else {
+ this.bind('t z', () => {
+ appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
+ });
+ }
this.bind('ctrl+z', () => {
appEvents.publish(new ZoomOutEvent({ scale: 2, updateUrl }));
diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts
index 856f8df3193..9a70858f39b 100644
--- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts
+++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.test.ts
@@ -1,4 +1,5 @@
import { LegacyGraphHoverClearEvent } from '@grafana/data';
+import { config } from '@grafana/runtime';
import { behaviors, sceneGraph, SceneTimeRange } from '@grafana/scenes';
import { DashboardCursorSync } from '@grafana/schema';
import { appEvents } from 'app/core/app_events';
@@ -253,4 +254,146 @@ describe('setupKeyboardShortcuts', () => {
expect(drBinding).toBeDefined();
});
});
+
+ describe('time range zoom shortcuts with feature toggle', () => {
+ describe('when newTimeRangeZoomShortcuts is enabled', () => {
+ beforeEach(() => {
+ config.featureToggles.newTimeRangeZoomShortcuts = true;
+ jest.clearAllMocks();
+ });
+
+ it('should setup t + zoom in shortcut', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
+ expect(tPlusBinding).toBeDefined();
+ });
+
+ it('should setup t - zoom out shortcut with keypress type', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tMinusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't -');
+ expect(tMinusBinding).toBeDefined();
+ expect(tMinusBinding![0].type).toBe('keypress');
+ });
+
+ it('should not setup t z shortcut when feature toggle is on', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tzBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't z');
+ expect(tzBinding).toBeUndefined();
+ });
+ });
+
+ describe('when newTimeRangeZoomShortcuts is disabled', () => {
+ beforeEach(() => {
+ config.featureToggles.newTimeRangeZoomShortcuts = false;
+ jest.clearAllMocks();
+ });
+
+ it('should setup legacy t z shortcut', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tzBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't z');
+ expect(tzBinding).toBeDefined();
+ });
+
+ it('should not setup new zoom shortcuts when feature toggle is off', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
+ const tMinusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't -');
+
+ expect(tPlusBinding).toBeUndefined();
+ expect(tMinusBinding).toBeUndefined();
+ });
+ });
+
+ describe('zoom handler logic', () => {
+ let mockTimeRange: ReturnType;
+
+ function createMockTimeRange() {
+ return {
+ state: {
+ value: {
+ from: { valueOf: () => new Date('2024-01-01 12:00:00').getTime() },
+ to: { valueOf: () => new Date('2024-01-01 18:00:00').getTime() }, // 6 hour span
+ raw: { from: 'now-6h', to: 'now' },
+ },
+ },
+ onTimeRangeChange: jest.fn(),
+ } satisfies {
+ state: {
+ value: {
+ from: { valueOf: () => number };
+ to: { valueOf: () => number };
+ raw: { from: string; to: string };
+ };
+ };
+ onTimeRangeChange: jest.Mock;
+ };
+ }
+
+ beforeEach(() => {
+ config.featureToggles.newTimeRangeZoomShortcuts = true;
+ mockTimeRange = createMockTimeRange();
+
+ (sceneGraph.getTimeRange as jest.Mock).mockReturnValue(mockTimeRange);
+ jest.clearAllMocks();
+ });
+
+ it('should zoom in (scale 0.5) when t + is pressed', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
+ const handler = tPlusBinding![0].onTrigger;
+
+ handler();
+
+ // Scale 0.5 should result in 3 hour span (half of 6)
+ expect(mockTimeRange.onTimeRangeChange).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: expect.any(Object),
+ to: expect.any(Object),
+ raw: expect.any(Object),
+ })
+ );
+
+ const call = mockTimeRange.onTimeRangeChange.mock.calls[0][0];
+ const newSpan = call.to.valueOf() - call.from.valueOf();
+ expect(newSpan).toBe(3 * 60 * 60 * 1000); // 3 hours in milliseconds
+ });
+
+ it('should keep center point when zooming in', () => {
+ setupKeyboardShortcuts(mockScene);
+
+ const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
+ const handler = tPlusBinding![0].onTrigger;
+
+ const originalCenter = (mockTimeRange.state.value.from.valueOf() + mockTimeRange.state.value.to.valueOf()) / 2;
+
+ handler();
+
+ const call = mockTimeRange.onTimeRangeChange.mock.calls[0][0];
+ const newCenter = (call.from.valueOf() + call.to.valueOf()) / 2;
+
+ expect(newCenter).toBe(originalCenter);
+ });
+
+ it('should do nothing when timespan is zero', () => {
+ mockTimeRange.state.value.from.valueOf = () => new Date('2024-01-01 12:00:00').getTime();
+ mockTimeRange.state.value.to.valueOf = () => new Date('2024-01-01 12:00:00').getTime(); // Same time
+
+ setupKeyboardShortcuts(mockScene);
+
+ const tPlusBinding = mockKeybindingSet.addBinding.mock.calls.find((call) => call[0].key === 't +');
+ const handler = tPlusBinding![0].onTrigger;
+
+ handler();
+
+ // Should not call onTimeRangeChange when timespan is 0
+ expect(mockTimeRange.onTimeRangeChange).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
index b6ebf75bf44..1113b8a8a8c 100644
--- a/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
+++ b/public/app/features/dashboard-scene/scene/keyboardShortcuts.ts
@@ -1,4 +1,4 @@
-import { locationUtil, SetPanelAttentionEvent, LegacyGraphHoverClearEvent } from '@grafana/data';
+import { locationUtil, SetPanelAttentionEvent, LegacyGraphHoverClearEvent, dateTime } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { behaviors, sceneGraph, VizPanel } from '@grafana/scenes';
import { appEvents } from 'app/core/app_events';
@@ -130,13 +130,29 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => sceneGraph.getTimeRange(scene).onRefresh(),
});
- // Zoom out
- keybindings.addBinding({
- key: 't z',
- onTrigger: () => {
- handleZoomOut(scene);
- },
- });
+ if (config.featureToggles.newTimeRangeZoomShortcuts) {
+ keybindings.addBinding({
+ key: 't +',
+ onTrigger: () => {
+ handleZoom(scene, 0.5);
+ },
+ });
+
+ keybindings.addBinding({
+ key: 't -',
+ type: 'keypress', // NOTE: Because some browsers/OS identify minus symbol differently.
+ onTrigger: () => {
+ handleZoomOut(scene);
+ },
+ });
+ } else {
+ keybindings.addBinding({
+ key: 't z',
+ onTrigger: () => {
+ handleZoomOut(scene);
+ },
+ });
+ }
keybindings.addBinding({
key: 'ctrl+z',
@@ -266,6 +282,31 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
};
}
+function handleZoom(scene: DashboardScene, scale: number) {
+ const timeRange = sceneGraph.getTimeRange(scene);
+ const currentRange = timeRange.state.value;
+ const timespan = currentRange.to.valueOf() - currentRange.from.valueOf();
+
+ if (timespan === 0) {
+ return;
+ }
+
+ const center = currentRange.to.valueOf() - timespan / 2;
+ const newTimespan = timespan * scale;
+
+ const to = center + newTimespan / 2;
+ const from = center - newTimespan / 2;
+
+ timeRange.onTimeRangeChange({
+ from: dateTime(from),
+ to: dateTime(to),
+ raw: {
+ from: dateTime(from),
+ to: dateTime(to),
+ },
+ });
+}
+
function handleZoomOut(scene: DashboardScene) {
const timePicker = dashboardSceneGraph.getTimePicker(scene);
timePicker?.onZoom();
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index ba66da11bbf..9539dbefc30 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -9297,6 +9297,7 @@
"toggle-panel-edit": "Toggle panel edit view",
"toggle-panel-fullscreen": "Toggle panel fullscreen view",
"toggle-panel-legend": "Toggle panel legend",
+ "zoom-in-time-range": "Zoom in time range",
"zoom-out-time-range": "Zoom out time range"
},
"title": "Shortcuts"