Skip to content

Commit 97098f2

Browse files
authored
feat: DH-19632: Add shortcuts to cycle stacks and panels (#2458)
Part of DH-19632. Introduces keyboard navigation to cycle between and within stacks. Navigating through shortcuts activates content items and respects any existing active selections. `Ctrl + '` / `Ctrl + ;`: to cycle forward/backward between different stacks in the layout `Ctrl + Shift + '` / `Ctrl + Shift + ;`: to cycle forward/backward between tabs within the currently focused stack Changes: - Create`NavigationEvent` system - Implement stack and tab cycling handlers in `PanelManager` - Add `getFocusedStack()` and `getFocusedStackIndex()` helper methods to `LayoutUtils` for focus detection - Add `forceFocus` parameter that bypasses the normal `isFocusonShow` check
1 parent 1c9a8ed commit 97098f2

9 files changed

Lines changed: 275 additions & 4 deletions

File tree

packages/code-studio/src/main/AppMainContainer.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
1414
import {
1515
ContextActions,
1616
GLOBAL_SHORTCUTS,
17+
NAVIGATION_SHORTCUTS,
1718
Popper,
1819
type ContextAction,
1920
Button,
@@ -39,6 +40,10 @@ import {
3940
setDashboardData as setDashboardDataAction,
4041
setDashboardPluginData as setDashboardPluginDataAction,
4142
updateDashboardData as updateDashboardDataAction,
43+
emitCycleToNextStack,
44+
emitCycleToPreviousStack,
45+
emitCycleToNextTab,
46+
emitCycleToPreviousTab,
4247
} from '@deephaven/dashboard';
4348
import {
4449
ConsolePlugin,
@@ -235,6 +240,34 @@ export class AppMainContainer extends Component<
235240
shortcut: IRIS_GRID_SHORTCUTS.TABLE.CLEAR_ALL_FILTERS,
236241
isGlobal: true,
237242
},
243+
{
244+
action: () => {
245+
this.sendCycleStackForward();
246+
},
247+
shortcut: NAVIGATION_SHORTCUTS.CYCLE_TO_NEXT_STACK,
248+
isGlobal: true,
249+
},
250+
{
251+
action: () => {
252+
this.sendCycleStackBackward();
253+
},
254+
shortcut: NAVIGATION_SHORTCUTS.CYCLE_TO_PREVIOUS_STACK,
255+
isGlobal: true,
256+
},
257+
{
258+
action: () => {
259+
this.sendCycleTabForward();
260+
},
261+
shortcut: NAVIGATION_SHORTCUTS.CYCLE_TO_NEXT_TAB,
262+
isGlobal: true,
263+
},
264+
{
265+
action: () => {
266+
this.sendCycleTabBackward();
267+
},
268+
shortcut: NAVIGATION_SHORTCUTS.CYCLE_TO_PREVIOUS_TAB,
269+
isGlobal: true,
270+
},
238271
{
239272
action: () => {
240273
this.sendReopenLast();
@@ -411,6 +444,22 @@ export class AppMainContainer extends Component<
411444
this.emitLayoutEvent(InputFilterEvent.CLEAR_ALL_FILTERS);
412445
}
413446

447+
sendCycleStackForward(): void {
448+
emitCycleToNextStack(this.getActiveEventHub());
449+
}
450+
451+
sendCycleStackBackward(): void {
452+
emitCycleToPreviousStack(this.getActiveEventHub());
453+
}
454+
455+
sendCycleTabForward(): void {
456+
emitCycleToNextTab(this.getActiveEventHub());
457+
}
458+
459+
sendCycleTabBackward(): void {
460+
emitCycleToPreviousTab(this.getActiveEventHub());
461+
}
462+
414463
sendReopenLast(): void {
415464
this.emitLayoutEvent(PanelEvent.REOPEN_LAST);
416465
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import ShortcutRegistry from './ShortcutRegistry';
2+
import { MODIFIER, KEY } from './Shortcut';
3+
4+
const NAVIGATION_SHORTCUTS = {
5+
CYCLE_TO_NEXT_STACK: ShortcutRegistry.createAndAdd({
6+
id: 'NAVIGATION.CYCLE_TO_NEXT_STACK',
7+
name: 'Cycle To Next Stack',
8+
shortcut: [MODIFIER.CTRL, KEY.SINGLE_QUOTE],
9+
macShortcut: [MODIFIER.CMD, KEY.SINGLE_QUOTE],
10+
isEditable: true,
11+
}),
12+
CYCLE_TO_PREVIOUS_STACK: ShortcutRegistry.createAndAdd({
13+
id: 'NAVIGATION.CYCLE_TO_PREVIOUS_STACK',
14+
name: 'Cycle To Previous Stack',
15+
shortcut: [MODIFIER.CTRL, KEY.SEMICOLON],
16+
macShortcut: [MODIFIER.CMD, KEY.SEMICOLON],
17+
isEditable: true,
18+
}),
19+
CYCLE_TO_NEXT_TAB: ShortcutRegistry.createAndAdd({
20+
id: 'NAVIGATION.CYCLE_TO_NEXT_TAB',
21+
name: 'Cycle To Next Tab',
22+
shortcut: [MODIFIER.CTRL, MODIFIER.SHIFT, KEY.DOUBLE_QUOTE],
23+
macShortcut: [MODIFIER.CMD, MODIFIER.SHIFT, KEY.SINGLE_QUOTE],
24+
isEditable: true,
25+
}),
26+
CYCLE_TO_PREVIOUS_TAB: ShortcutRegistry.createAndAdd({
27+
id: 'NAVIGATION.CYCLE_TO_PREVIOUS_TAB',
28+
name: 'Cycle To Previous TAB',
29+
shortcut: [MODIFIER.CTRL, MODIFIER.SHIFT, KEY.COLON],
30+
macShortcut: [MODIFIER.CMD, MODIFIER.SHIFT, KEY.SEMICOLON],
31+
isEditable: true,
32+
}),
33+
};
34+
35+
export default NAVIGATION_SHORTCUTS;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { default as GLOBAL_SHORTCUTS } from './GlobalShortcuts';
2+
export { default as NAVIGATION_SHORTCUTS } from './NavigationShortcuts';
23
export { default as Shortcut } from './Shortcut';
34
export * from './Shortcut';
45
export { default as ShortcutRegistry } from './ShortcutRegistry';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { makeEventFunctions } from '@deephaven/golden-layout';
2+
3+
const NavigationEvent = Object.freeze({
4+
CYCLE_TO_NEXT_STACK: 'NavigationEvent.CYCLE_TO_NEXT_STACK',
5+
CYCLE_TO_PREVIOUS_STACK: 'NavigationEvent.CYCLE_TO_PREVIOUS_STACK',
6+
CYCLE_TO_NEXT_TAB: 'NavigationEvent.CYCLE_TO_NEXT_TAB',
7+
CYCLE_TO_PREVIOUS_TAB: 'NavigationEvent.CYCLE_TO_PREVIOUS_TAB',
8+
});
9+
10+
export const {
11+
listen: listenForCycleToNextStack,
12+
emit: emitCycleToNextStack,
13+
useListener: useCycleToNextStackListener,
14+
} = makeEventFunctions(NavigationEvent.CYCLE_TO_NEXT_STACK);
15+
16+
export const {
17+
listen: listenForCycleToPreviousStack,
18+
emit: emitCycleToPreviousStack,
19+
useListener: useCycleToPreviousStackListener,
20+
} = makeEventFunctions(NavigationEvent.CYCLE_TO_PREVIOUS_STACK);
21+
22+
export const {
23+
listen: listenForCycleToNextTab,
24+
emit: emitCycleToNextTab,
25+
useListener: useCycleToNextTabListener,
26+
} = makeEventFunctions(NavigationEvent.CYCLE_TO_NEXT_TAB);
27+
28+
export const {
29+
listen: listenForCycleToPreviousTab,
30+
emit: emitCycleToPreviousTab,
31+
useListener: useCycleToPreviousTabListener,
32+
} = makeEventFunctions(NavigationEvent.CYCLE_TO_PREVIOUS_TAB);
33+
34+
export default NavigationEvent;

packages/dashboard/src/PanelManager.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import type {
88
} from '@deephaven/golden-layout';
99
import Log from '@deephaven/log';
1010
import PanelEvent from './PanelEvent';
11+
import {
12+
listenForCycleToNextStack,
13+
listenForCycleToPreviousStack,
14+
listenForCycleToNextTab,
15+
listenForCycleToPreviousTab,
16+
} from './NavigationEvent';
1117
import LayoutUtils, { isReactComponentConfig } from './layout/LayoutUtils';
1218
import {
1319
isWrappedComponent,
@@ -18,6 +24,11 @@ import {
1824

1925
const log = Log.module('PanelManager');
2026

27+
enum CycleDirection {
28+
Next,
29+
Previous,
30+
}
31+
2132
export type PanelHydraterFunction = (
2233
name: string,
2334
props: PanelProps
@@ -64,6 +75,8 @@ class PanelManager {
6475

6576
openedMap: OpenedPanelMap;
6677

78+
navigationEventListenerRemovers: (() => void)[];
79+
6780
/**
6881
* @param layout The GoldenLayout object to attach to
6982
* @param hydrateComponent Function to hydrate a panel from a dehydrated state
@@ -88,6 +101,11 @@ class PanelManager {
88101
this.handleUnmount = this.handleUnmount.bind(this);
89102
this.handleReopen = this.handleReopen.bind(this);
90103
this.handleReopenLast = this.handleReopenLast.bind(this);
104+
this.handleCycleToNextStack = this.handleCycleToNextStack.bind(this);
105+
this.handleCycleToPreviousStack =
106+
this.handleCycleToPreviousStack.bind(this);
107+
this.handleCycleToNextTab = this.handleCycleToNextTab.bind(this);
108+
this.handleCycleToPreviousTab = this.handleCycleToPreviousTab.bind(this);
91109
this.handleDeleted = this.handleDeleted.bind(this);
92110
this.handleClosed = this.handleClosed.bind(this);
93111
this.handleControlClose = this.handleControlClose.bind(this);
@@ -103,6 +121,9 @@ class PanelManager {
103121
// Closed panels are stored in their dehydrated state
104122
this.closed = [...closed];
105123

124+
// Store the navigation event listener removers
125+
this.navigationEventListenerRemovers = [];
126+
106127
this.startListening();
107128
}
108129

@@ -117,6 +138,19 @@ class PanelManager {
117138
eventHub.on(PanelEvent.CLOSED, this.handleClosed);
118139
eventHub.on(PanelEvent.CLOSE, this.handleControlClose);
119140
// PanelEvent.OPEN should be listened to by plugins to open a panel
141+
142+
this.navigationEventListenerRemovers.push(
143+
listenForCycleToNextStack(eventHub, this.handleCycleToNextStack)
144+
);
145+
this.navigationEventListenerRemovers.push(
146+
listenForCycleToPreviousStack(eventHub, this.handleCycleToPreviousStack)
147+
);
148+
this.navigationEventListenerRemovers.push(
149+
listenForCycleToNextTab(eventHub, this.handleCycleToNextTab)
150+
);
151+
this.navigationEventListenerRemovers.push(
152+
listenForCycleToPreviousTab(eventHub, this.handleCycleToPreviousTab)
153+
);
120154
}
121155

122156
stopListening(): void {
@@ -129,6 +163,9 @@ class PanelManager {
129163
eventHub.off(PanelEvent.DELETE, this.handleDeleted);
130164
eventHub.off(PanelEvent.CLOSED, this.handleClosed);
131165
eventHub.off(PanelEvent.CLOSE, this.handleControlClose);
166+
167+
this.navigationEventListenerRemovers.forEach(remover => remover());
168+
this.navigationEventListenerRemovers = [];
132169
}
133170

134171
getClosedPanelConfigsOfType(typeString: string): ClosedPanels {
@@ -271,6 +308,79 @@ class PanelManager {
271308
this.sendUpdate();
272309
}
273310

311+
cycleStack(direction: CycleDirection): void {
312+
const allStacks = LayoutUtils.getAllStackContainers(this.layout);
313+
if (allStacks.length <= 1) {
314+
return;
315+
}
316+
317+
const focusedIndex = LayoutUtils.getFocusedStackIndex(allStacks);
318+
319+
// If no stack is focused, activate the first stack's content item
320+
if (focusedIndex === -1) {
321+
const targetStack = allStacks[0];
322+
const activeContentIndex = targetStack.config.activeItemIndex;
323+
const activeContentItem =
324+
activeContentIndex != null
325+
? targetStack.contentItems[activeContentIndex]
326+
: targetStack.contentItems[0];
327+
328+
targetStack.setActiveContentItem(activeContentItem, true);
329+
return;
330+
}
331+
332+
const targetIndex =
333+
direction === CycleDirection.Next
334+
? (focusedIndex + 1) % allStacks.length
335+
: (focusedIndex - 1 + allStacks.length) % allStacks.length;
336+
const targetStack = allStacks[targetIndex];
337+
338+
const activeContentIndex = targetStack.config.activeItemIndex;
339+
const activeContentItem =
340+
activeContentIndex != null
341+
? targetStack.contentItems[activeContentIndex]
342+
: targetStack.contentItems[0];
343+
344+
targetStack.setActiveContentItem(activeContentItem, true);
345+
}
346+
347+
handleCycleToNextStack(): void {
348+
this.cycleStack(CycleDirection.Next);
349+
}
350+
351+
handleCycleToPreviousStack(): void {
352+
this.cycleStack(CycleDirection.Previous);
353+
}
354+
355+
cycleTab(direction: CycleDirection): void {
356+
const focusedStack = LayoutUtils.getFocusedStack(this.layout);
357+
if (focusedStack === undefined) {
358+
return;
359+
}
360+
const { contentItems } = focusedStack;
361+
362+
if (contentItems.length <= 1) {
363+
return;
364+
}
365+
366+
const activeItemIndex = focusedStack.config.activeItemIndex ?? 0;
367+
const targetIndex =
368+
direction === CycleDirection.Next
369+
? (activeItemIndex + 1) % contentItems.length
370+
: (activeItemIndex - 1 + contentItems.length) % contentItems.length;
371+
372+
const targetContentItem = contentItems[targetIndex];
373+
focusedStack.setActiveContentItem(targetContentItem, true);
374+
}
375+
376+
handleCycleToNextTab(): void {
377+
this.cycleTab(CycleDirection.Next);
378+
}
379+
380+
handleCycleToPreviousTab(): void {
381+
this.cycleTab(CycleDirection.Previous);
382+
}
383+
274384
/**
275385
*
276386
* @param panelConfig The config to hydrate and load

packages/dashboard/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export {
2020
} from './BasePanel';
2121
export * from './PanelManager';
2222
export * from './PanelEvent';
23+
export * from './NavigationEvent';
2324
export { default as PanelErrorBoundary } from './PanelErrorBoundary';
2425
export { default as PanelManager } from './PanelManager';
2526
export { default as TabEvent } from './TabEvent';

packages/dashboard/src/layout/LayoutUtils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,43 @@ class LayoutUtils {
164164
return this.addStack(newParent, !columnPreferred);
165165
}
166166

167+
/**
168+
* Gets all stack containers in the layout
169+
* @param layout GoldenLayout instance
170+
* @returns The found stack containers
171+
*/
172+
static getAllStackContainers(layout: GoldenLayout): Stack[] {
173+
// eslint-disable-next-line no-underscore-dangle
174+
return layout._findAllStackContainers();
175+
}
176+
177+
/**
178+
* Get the index of the stack that is currently focused
179+
* @param allStacks All the stacks
180+
* @returns The focused stack's index or -1 if not found
181+
*/
182+
static getFocusedStackIndex(allStacks: Stack[]): number {
183+
// NOTE: We target the 'lm_focusin' class because GoldenLayout automatically applies this class
184+
// to tab elements when they receive focus. Until we enhance focus tracking in GoldenLayout, we
185+
// will have to rely on this internal CSS class.
186+
return allStacks.findIndex(stack =>
187+
stack.header.tabs.some(tab =>
188+
tab.element[0].classList.contains('lm_focusin')
189+
)
190+
);
191+
}
192+
193+
/**
194+
* Get the stack that is currently focused
195+
* @param layout GoldenLayout instance
196+
* @returns The focused stack or undefined if none found
197+
*/
198+
static getFocusedStack(layout: GoldenLayout): Stack | undefined {
199+
const allStacks = LayoutUtils.getAllStackContainers(layout);
200+
const focusedStackIndex = LayoutUtils.getFocusedStackIndex(allStacks);
201+
return allStacks[focusedStackIndex];
202+
}
203+
167204
/**
168205
* Gets a stack by its ID
169206
* @param item Golden layout content item to search for the stack

packages/golden-layout/src/items/Component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,9 @@ export default class Component extends AbstractContentItem {
8686
AbstractContentItem.prototype._$hide.call(this);
8787
}
8888

89-
_$show() {
89+
_$show(forceFocus?: boolean) {
9090
this.container.show();
91-
if (this.container._config.isFocusOnShow) {
91+
if (this.container._config.isFocusOnShow || forceFocus) {
9292
// focus the shown container element on show
9393
// preventScroll isn't supported in safari, but also doesn't matter for illumon when 100% window
9494
this.container._contentElement[0].focus({ preventScroll: true });

0 commit comments

Comments
 (0)