From c220a8b070e9037710f9d1a8e6c3ad454ce31d6c Mon Sep 17 00:00:00 2001 From: Ashley Stanton-Nurse Date: Fri, 19 Apr 2024 13:44:30 -0700 Subject: [PATCH] [Task 3071878] Tab Navigation Keyboard Shortcuts (#1808) * [Task 3071878] Tab Navigation Keyboard Shortcuts * throw in development on duplicate handlers * refmt --- .../CommandBar/CommandBarComponentAdapter.tsx | 6 +- src/Explorer/Tabs/Tabs.tsx | 11 ++++ src/KeyboardShortcuts.tsx | 64 +++++++++++++++++-- src/hooks/useTabs.ts | 46 +++++++++++++ 4 files changed, 118 insertions(+), 9 deletions(-) diff --git a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx index a5db3826c..9a5f222a3 100644 --- a/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx +++ b/src/Explorer/Menus/CommandBar/CommandBarComponentAdapter.tsx @@ -5,7 +5,7 @@ */ import { CommandBar as FluentCommandBar, ICommandBarItemProps } from "@fluentui/react"; import { useNotebook } from "Explorer/Notebook/useNotebook"; -import { useKeyboardActionHandlers } from "KeyboardShortcuts"; +import { KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { userContext } from "UserContext"; import * as React from "react"; import create, { UseStore } from "zustand"; @@ -41,7 +41,7 @@ export const CommandBar: React.FC = ({ container }: Props) => { const buttons = useCommandBar((state) => state.contextButtons); const isHidden = useCommandBar((state) => state.isHidden); const backgroundColor = StyleConstants.BaseLight; - const setKeyboardActionHandlers = useKeyboardActionHandlers((state) => state.setHandlers); + const setKeyboardHandlers = useKeyboardActionGroup(KeyboardActionGroup.COMMAND_BAR); if (userContext.apiType === "Postgres" || userContext.apiType === "VCoreMongo") { const buttons = @@ -109,7 +109,7 @@ export const CommandBar: React.FC = ({ container }: Props) => { const allButtons = staticButtons.concat(contextButtons).concat(controlButtons); const keyboardHandlers = CommandBarUtil.createKeyboardHandlers(allButtons); - setKeyboardActionHandlers(keyboardHandlers); + setKeyboardHandlers(keyboardHandlers); return (
diff --git a/src/Explorer/Tabs/Tabs.tsx b/src/Explorer/Tabs/Tabs.tsx index 78281bf62..b33f3d4af 100644 --- a/src/Explorer/Tabs/Tabs.tsx +++ b/src/Explorer/Tabs/Tabs.tsx @@ -14,6 +14,7 @@ import { PostgresConnectTab } from "Explorer/Tabs/PostgresConnectTab"; import { QuickstartTab } from "Explorer/Tabs/QuickstartTab"; import { VcoreMongoConnectTab } from "Explorer/Tabs/VCoreMongoConnectTab"; import { VcoreMongoQuickstartTab } from "Explorer/Tabs/VCoreMongoQuickstartTab"; +import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts"; import { hasRUThresholdBeenConfigured } from "Shared/StorageUtility"; import { userContext } from "UserContext"; import { CassandraProxyOutboundIPs, MongoProxyOutboundIPs, PortalBackendIPs } from "Utils/EndpointUtils"; @@ -42,6 +43,16 @@ export const Tabs = ({ explorer }: TabsProps): JSX.Element => { showMongoAndCassandraProxiesNetworkSettingsWarningState, setShowMongoAndCassandraProxiesNetworkSettingsWarningState, ] = useState(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 (
{networkSettingsWarning && ( diff --git a/src/KeyboardShortcuts.tsx b/src/KeyboardShortcuts.tsx index dbf6dddb7..66efd68b4 100644 --- a/src/KeyboardShortcuts.tsx +++ b/src/KeyboardShortcuts.tsx @@ -12,6 +12,15 @@ export type KeyboardActionHandler = (e: KeyboardEvent) => boolean | void; export type KeyboardHandlerMap = Partial>; +/** + * 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. */ @@ -30,6 +39,9 @@ export enum KeyboardAction { NEW_ITEM = "NEW_ITEM", DELETE_ITEM = "DELETE_ITEM", 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.NEW_ITEM]: ["Alt+N I"], [KeyboardAction.DELETE_ITEM]: ["Alt+D"], [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 { @@ -64,15 +79,47 @@ interface KeyboardShortcutState { allHandlers: KeyboardHandlerMap; /** - * Sets the keyboard shortcut handlers. + * A set of all the groups of keyboard shortcuts handlers. */ - setHandlers: (handlers: KeyboardHandlerMap) => void; + groups: Partial>; + + /** + * Sets the keyboard shortcut handlers for the given group. + */ + setHandlers: (group: KeyboardActionGroup, handlers: KeyboardHandlerMap) => void; } -export const useKeyboardActionHandlers: UseStore = 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 = create((set, get) => ({ allHandlers: {}, - setHandlers: (handlers: Partial>) => { - set({ allHandlers: handlers }); + groups: {}, + 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 = {}; -(Object.keys(bindings) as KeyboardAction[]).forEach((action) => { +eachKey(bindings).forEach((action) => { const shortcuts = bindings[action]; shortcuts.forEach((shortcut) => { allHandlers[shortcut] = createHandler(action); @@ -103,3 +150,8 @@ export function KeyboardShortcutRoot({ children }: PropsWithChildren) { return <>{children}; } + +/** A _typed_ version of `Object.keys` that preserves the original key type */ +function eachKey(record: Partial>): K[] { + return Object.keys(record) as K[]; +} diff --git a/src/hooks/useTabs.ts b/src/hooks/useTabs.ts index 10bc3b144..982768afa 100644 --- a/src/hooks/useTabs.ts +++ b/src/hooks/useTabs.ts @@ -1,3 +1,4 @@ +import { clamp } from "@fluentui/react"; import create, { UseStore } from "zustand"; import * as ViewModels from "../Contracts/ViewModels"; import { CollectionTabKind } from "../Contracts/ViewModels"; @@ -29,6 +30,11 @@ export interface TabsState { setQueryCopilotTabInitialInput: (input: string) => void; setIsTabExecuting: (state: boolean) => void; setIsQueryErrorThrown: (state: boolean) => void; + getCurrentTabIndex: () => number; + selectTabByIndex: (index: number) => void; + selectLeftTab: () => void; + selectRightTab: () => void; + closeActiveTab: () => void; } export enum ReactTabKind { @@ -175,4 +181,44 @@ export const useTabs: UseStore = create((set, get) => ({ setIsQueryErrorThrown: (state: boolean) => { 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); + } + }, }));