[Task 3071878] Tab Navigation Keyboard Shortcuts (#1808)
* [Task 3071878] Tab Navigation Keyboard Shortcuts * throw in development on duplicate handlers * refmt
This commit is contained in:
parent
a5a5a95973
commit
c220a8b070
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react";
|
||||||
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
import { useNotebook } from "Explorer/Notebook/useNotebook";
|
||||||
import { useKeyboardActionHandlers } from "KeyboardShortcuts";
|
import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import create, { UseStore } from "zustand";
|
import create, { UseStore } from "zustand";
|
||||||
|
@ -41,7 +41,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
|
||||||
const buttons = useCommandBar((state) => state.contextButtons);
|
const buttons = useCommandBar((state) => state.contextButtons);
|
||||||
const isHidden = useCommandBar((state) => state.isHidden);
|
const isHidden = useCommandBar((state) => state.isHidden);
|
||||||
const backgroundColor = StyleConstants.BaseLight;
|
const backgroundColor = StyleConstants.BaseLight;
|
||||||
const setKeyboardActionHandlers = useKeyboardActionHandlers((state) => state.setHandlers);
|
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR);
|
||||||
|
|
||||||
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
|
if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") {
|
||||||
const buttons =
|
const buttons =
|
||||||
|
@ -109,7 +109,7 @@ export const CommandBar: React.FC<Props> = ({ container }: Props) => {
|
||||||
|
|
||||||
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
|
const allButtons = staticButtons.concat(contextButtons).concat(controlButtons);
|
||||||
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);
|
const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons);
|
||||||
setKeyboardActionHandlers(keyboardHandlers);
|
setKeyboardHandlers(keyboardHandlers);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
|
<div className="commandBarContainer" style={{ display: isHidden ? "none" : "initial" }}>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { PostgresConnectTab } from "Explorer/Tabs/PostgresConnectTab";
|
||||||
import { QuickstartTab } from "Explorer/Tabs/QuickstartTab";
|
import { QuickstartTab } from "Explorer/Tabs/QuickstartTab";
|
||||||
import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
|
import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab";
|
||||||
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
|
import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab";
|
||||||
|
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
|
||||||
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
|
import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
|
import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils";
|
||||||
|
@ -42,6 +43,16 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => {
|
||||||
showMongoAndCassandraProxiesNetworkSettingsWarningState,
|
showMongoAndCassandraProxiesNetworkSettingsWarningState,
|
||||||
setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
|
setShowMongoAndCassandraProxiesNetworkSettingsWarningState,
|
||||||
] = useState<boolean>(showMongoAndCassandraProxiesNetworkSettingsWarning());
|
] = useState<boolean>(showMongoAndCassandraProxiesNetworkSettingsWarning());
|
||||||
|
|
||||||
|
const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.TABS);
|
||||||
|
useEffect(() => {
|
||||||
|
setKeyboardHandlers({
|
||||||
|
[KeyboardAction.SELECT_LEFT_TAB]: () => useTabs.getState().selectLeftTab(),
|
||||||
|
[KeyboardAction.SELECT_RIGHT_TAB]: () => useTabs.getState().selectRightTab(),
|
||||||
|
[KeyboardAction.CLOSE_TAB]: () => useTabs.getState().closeActiveTab(),
|
||||||
|
});
|
||||||
|
}, [setKeyboardHandlers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tabsManagerContainer">
|
<div className="tabsManagerContainer">
|
||||||
{networkSettingsWarning && (
|
{networkSettingsWarning && (
|
||||||
|
|
|
@ -12,6 +12,15 @@ export type KeyboardActionHandler = (e: KeyboardEvent) => boolean | void;
|
||||||
|
|
||||||
export type KeyboardHandlerMap = Partial<Record<KeyboardAction, KeyboardActionHandler>>;
|
export type KeyboardHandlerMap = Partial<Record<KeyboardAction, KeyboardActionHandler>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The groups of keyboard actions that can be managed by the application.
|
||||||
|
* Each group can be updated separately, but, when updated, must be completely replaced.
|
||||||
|
*/
|
||||||
|
export enum KeyboardActionGroup {
|
||||||
|
TABS = "TABS",
|
||||||
|
COMMAND_BAR = "COMMAND_BAR",
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The possible actions that can be triggered by keyboard shortcuts.
|
* The possible actions that can be triggered by keyboard shortcuts.
|
||||||
*/
|
*/
|
||||||
|
@ -30,6 +39,9 @@ export enum KeyboardAction {
|
||||||
NEW_ITEM = "NEW_ITEM",
|
NEW_ITEM = "NEW_ITEM",
|
||||||
DELETE_ITEM = "DELETE_ITEM",
|
DELETE_ITEM = "DELETE_ITEM",
|
||||||
TOGGLE_COPILOT = "TOGGLE_COPILOT",
|
TOGGLE_COPILOT = "TOGGLE_COPILOT",
|
||||||
|
SELECT_LEFT_TAB = "SELECT_LEFT_TAB",
|
||||||
|
SELECT_RIGHT_TAB = "SELECT_RIGHT_TAB",
|
||||||
|
CLOSE_TAB = "CLOSE_TAB",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -55,6 +67,9 @@ const bindings: Record<KeyboardAction, string[]> = {
|
||||||
[KeyboardAction.NEW_ITEM]: ["Alt+N I"],
|
[KeyboardAction.NEW_ITEM]: ["Alt+N I"],
|
||||||
[KeyboardAction.DELETE_ITEM]: ["Alt+D"],
|
[KeyboardAction.DELETE_ITEM]: ["Alt+D"],
|
||||||
[KeyboardAction.TOGGLE_COPILOT]: ["$mod+P"],
|
[KeyboardAction.TOGGLE_COPILOT]: ["$mod+P"],
|
||||||
|
[KeyboardAction.SELECT_LEFT_TAB]: ["$mod+Alt+["],
|
||||||
|
[KeyboardAction.SELECT_RIGHT_TAB]: ["$mod+Alt+]"],
|
||||||
|
[KeyboardAction.CLOSE_TAB]: ["$mod+Alt+W"],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface KeyboardShortcutState {
|
interface KeyboardShortcutState {
|
||||||
|
@ -64,15 +79,47 @@ interface KeyboardShortcutState {
|
||||||
allHandlers: KeyboardHandlerMap;
|
allHandlers: KeyboardHandlerMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the keyboard shortcut handlers.
|
* A set of all the groups of keyboard shortcuts handlers.
|
||||||
*/
|
*/
|
||||||
setHandlers: (handlers: KeyboardHandlerMap) => void;
|
groups: Partial<Record<KeyboardActionGroup, KeyboardHandlerMap>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the keyboard shortcut handlers for the given group.
|
||||||
|
*/
|
||||||
|
setHandlers: (group: KeyboardActionGroup, handlers: KeyboardHandlerMap) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useKeyboardActionHandlers: UseStore<KeyboardShortcutState> = create((set) => ({
|
/**
|
||||||
|
* Defines the calling component as the manager of the keyboard actions for the given group.
|
||||||
|
* @param group The group of keyboard actions to manage.
|
||||||
|
* @returns A function that can be used to set the keyboard action handlers for the given group.
|
||||||
|
*/
|
||||||
|
export const useKeyboardActionGroup = (group: KeyboardActionGroup) => (handlers: KeyboardHandlerMap) =>
|
||||||
|
useKeyboardActionHandlers.getState().setHandlers(group, handlers);
|
||||||
|
|
||||||
|
const useKeyboardActionHandlers: UseStore<KeyboardShortcutState> = create((set, get) => ({
|
||||||
allHandlers: {},
|
allHandlers: {},
|
||||||
setHandlers: (handlers: Partial<Record<KeyboardAction, KeyboardActionHandler>>) => {
|
groups: {},
|
||||||
set({ allHandlers: handlers });
|
setHandlers: (group: KeyboardActionGroup, handlers: KeyboardHandlerMap) => {
|
||||||
|
const state = get();
|
||||||
|
const groups = { ...state.groups, [group]: handlers };
|
||||||
|
|
||||||
|
// Combine all the handlers from all the groups in the correct order.
|
||||||
|
const allHandlers: KeyboardHandlerMap = {};
|
||||||
|
eachKey(groups).forEach((group) => {
|
||||||
|
const groupHandlers = groups[group];
|
||||||
|
if (groupHandlers) {
|
||||||
|
eachKey(groupHandlers).forEach((action) => {
|
||||||
|
// Check for duplicate handlers in development mode.
|
||||||
|
// We don't want to raise an error here in production, but having duplicate handlers is a mistake.
|
||||||
|
if (process.env.NODE_ENV === "development" && allHandlers[action]) {
|
||||||
|
throw new Error(`Duplicate handler for Keyboard Action "${action}".`);
|
||||||
|
}
|
||||||
|
allHandlers[action] = groupHandlers[action];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set({ groups, allHandlers });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -88,7 +135,7 @@ function createHandler(action: KeyboardAction): KeyboardActionHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
const allHandlers: KeyBindingMap = {};
|
const allHandlers: KeyBindingMap = {};
|
||||||
(Object.keys(bindings) as KeyboardAction[]).forEach((action) => {
|
eachKey(bindings).forEach((action) => {
|
||||||
const shortcuts = bindings[action];
|
const shortcuts = bindings[action];
|
||||||
shortcuts.forEach((shortcut) => {
|
shortcuts.forEach((shortcut) => {
|
||||||
allHandlers[shortcut] = createHandler(action);
|
allHandlers[shortcut] = createHandler(action);
|
||||||
|
@ -103,3 +150,8 @@ export function KeyboardShortcutRoot({ children }: PropsWithChildren<unknown>) {
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A _typed_ version of `Object.keys` that preserves the original key type */
|
||||||
|
function eachKey<K extends string | number | symbol, V>(record: Partial<Record<K, V>>): K[] {
|
||||||
|
return Object.keys(record) as K[];
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { clamp } from "@fluentui/react";
|
||||||
import create, { UseStore } from "zustand";
|
import create, { UseStore } from "zustand";
|
||||||
import * as ViewModels from "../Contracts/ViewModels";
|
import * as ViewModels from "../Contracts/ViewModels";
|
||||||
import { CollectionTabKind } from "../Contracts/ViewModels";
|
import { CollectionTabKind } from "../Contracts/ViewModels";
|
||||||
|
@ -29,6 +30,11 @@ export interface TabsState {
|
||||||
setQueryCopilotTabInitialInput: (input: string) => void;
|
setQueryCopilotTabInitialInput: (input: string) => void;
|
||||||
setIsTabExecuting: (state: boolean) => void;
|
setIsTabExecuting: (state: boolean) => void;
|
||||||
setIsQueryErrorThrown: (state: boolean) => void;
|
setIsQueryErrorThrown: (state: boolean) => void;
|
||||||
|
getCurrentTabIndex: () => number;
|
||||||
|
selectTabByIndex: (index: number) => void;
|
||||||
|
selectLeftTab: () => void;
|
||||||
|
selectRightTab: () => void;
|
||||||
|
closeActiveTab: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReactTabKind {
|
export enum ReactTabKind {
|
||||||
|
@ -175,4 +181,44 @@ export const useTabs: UseStore<TabsState> = create((set, get) => ({
|
||||||
setIsQueryErrorThrown: (state: boolean) => {
|
setIsQueryErrorThrown: (state: boolean) => {
|
||||||
set({ isQueryErrorThrown: state });
|
set({ isQueryErrorThrown: state });
|
||||||
},
|
},
|
||||||
|
getCurrentTabIndex: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.activeReactTab !== undefined) {
|
||||||
|
return state.openedReactTabs.indexOf(state.activeReactTab);
|
||||||
|
} else if (state.activeTab !== undefined) {
|
||||||
|
const nonReactTabIndex = state.openedTabs.indexOf(state.activeTab);
|
||||||
|
if (nonReactTabIndex !== -1) {
|
||||||
|
return state.openedReactTabs.length + nonReactTabIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
},
|
||||||
|
selectTabByIndex: (index: number) => {
|
||||||
|
const state = get();
|
||||||
|
const totalTabCount = state.openedReactTabs.length + state.openedTabs.length;
|
||||||
|
const clampedIndex = clamp(index, totalTabCount - 1, 0);
|
||||||
|
|
||||||
|
if (clampedIndex < state.openedReactTabs.length) {
|
||||||
|
set({ activeTab: undefined, activeReactTab: state.openedReactTabs[clampedIndex] });
|
||||||
|
} else {
|
||||||
|
set({ activeTab: state.openedTabs[clampedIndex - state.openedReactTabs.length], activeReactTab: undefined });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectLeftTab: () => {
|
||||||
|
const state = get();
|
||||||
|
state.selectTabByIndex(state.getCurrentTabIndex() - 1);
|
||||||
|
},
|
||||||
|
selectRightTab: () => {
|
||||||
|
const state = get();
|
||||||
|
state.selectTabByIndex(state.getCurrentTabIndex() + 1);
|
||||||
|
},
|
||||||
|
closeActiveTab: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.activeReactTab !== undefined) {
|
||||||
|
state.closeReactTab(state.activeReactTab);
|
||||||
|
} else if (state.activeTab !== undefined) {
|
||||||
|
state.closeTab(state.activeTab);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
Loading…
Reference in New Issue