diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index aa10bad1e5..4fd6fab36a 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -359,7 +359,7 @@ "Message": "Message", "Open in New Tab": "Open in New Tab", "Showplan XML": "Showplan XML", - "Show Menu": "Show Menu", + "Show Menu (F3)": "Show Menu (F3)", "Sort Ascending": "Sort Ascending", "Sort Descending": "Sort Descending", "Clear Sort": "Clear Sort", @@ -409,6 +409,7 @@ "message": "{0} selected", "comment": ["{0} is the number of selected rows"] }, + "Sort": "Sort", "Add new column": "Add new column", "Table": "Table", "Save": "Save", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 4b7bcb4146..cb2321f728 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -2935,8 +2935,8 @@ Show MSSQL output - - Show Menu + + Show Menu (F3) Show New Password @@ -3000,6 +3000,9 @@ Smart performance + + Sort + Sort Ascending diff --git a/src/reactviews/common/constants.ts b/src/reactviews/common/constants.ts index 016c03e3f1..75ff311859 100644 --- a/src/reactviews/common/constants.ts +++ b/src/reactviews/common/constants.ts @@ -4,3 +4,9 @@ *--------------------------------------------------------------------------------------------*/ export const addNewMicrosoftAccount = "##_addNewMicrosoftAccount_##"; + +export const cmdAKeyboardShortcut = "⌘A"; +export const ctrlAKeyboardShortcut = "Ctrl+A"; +export const cmdCKeyboardShortcut = "⌘C"; +export const ctrlCKeyboardShortcut = "Ctrl+C"; +export const altShiftOKeyboardShortcut = "Shift+Alt+O"; diff --git a/src/reactviews/common/icons/FLUENT_ICONS.md b/src/reactviews/common/icons/FLUENT_ICONS.md index 4c88a3b624..2507a89dc1 100644 --- a/src/reactviews/common/icons/FLUENT_ICONS.md +++ b/src/reactviews/common/icons/FLUENT_ICONS.md @@ -1,59 +1,60 @@ ## Creating custom React Fluent icons 1. Clean up - Fluent icons are constructed with an array of paths, but without any `fill-rule` or `clip-rule` entries. You can use the free/OSS Inkscape to clean this up easily: + Fluent icons are constructed with an array of paths, but without any `fill-rule` or `clip-rule` entries. You can use the free/OSS Inkscape to clean this up easily: - 1. Open your SVG - * File → Open… and load your SVG. - * Make sure your shape with fill-rule="evenodd" is visible. + 1. Open your SVG - 2. Select the path(s) - * Use the Select tool (S) and click the object. - * If it’s a compound path, you may need Object → Ungroup first. + - File → Open… and load your SVG. + - Make sure your shape with fill-rule="evenodd" is visible. - 3. Convert the shape into geometry that respects the evenodd fill - * With the path selected, go to: - * Path → Break Apart (Shift+Ctrl+K) - * This splits the path into its component sub-paths. - * Inkscape interprets the evenodd rule at this step: “holes” become independent paths. + 2. Select the path(s) - 4. Subtract the holes (if present) - * Select the main outer shape, then the hole shapes. - * Use Path → Difference (Ctrl+-) to cut the holes out. - * Repeat until all inner holes are cut away. - * Now you have pure geometry with no reliance on fill-rule. + - Use the Select tool (S) and click the object. + - If it’s a compound path, you may need Object → Ungroup first. + + 3. Convert the shape into geometry that respects the evenodd fill + + - With the path selected, go to: + - Path → Break Apart (Shift+Ctrl+K) + - This splits the path into its component sub-paths. + - Inkscape interprets the evenodd rule at this step: “holes” become independent paths. + + 4. Subtract the holes (if present) + - Select the main outer shape, then the hole shapes. + - Use Path → Difference (Ctrl+-) to cut the holes out. + - Repeat until all inner holes are cut away. + - Now you have pure geometry with no reliance on fill-rule. 2. Use this script to scale all the paths to your target size: - `npm install svgpath` + `npm install svgpath` - ```js - // scaleSvg.js + ```js + // scaleSvg.js - import svgpath from 'svgpath'; + import svgpath from "svgpath"; - const fabricPath = "M 42.400391..."; // replace with your path + const fabricPath = "M 42.400391..."; // replace with your path - const currentViewboxSize = 40; // replace with the viewbox from your existing SVG - const targetSize = 20; // replace with your target viewbox size + const currentViewboxSize = 40; // replace with the viewbox from your existing SVG + const targetSize = 20; // replace with your target viewbox size - const scale = targetSize / currentViewboxSize; + const scale = targetSize / currentViewboxSize; - const scaled = svgpath(fabricPath) - .scale(scale) - .toString(); + const scaled = svgpath(fabricPath).scale(scale).toString(); - console.log(scaled); - ``` + console.log(scaled); + ``` - `node scaleSvg.js` + `node scaleSvg.js` 3. Create the React icon: - ```ts - // in this example, the target size is 20. + ```ts + // in this example, the target size is 20. - import { createFluentIcon } from "@fluentui/react-icons"; - const iconPath = "M19.2729..."; // paths scaled to target size 20, from previous step - export const CustomIcon20 = createFluentIcon("CustomIcon20", "20", [iconPath]); - ``` \ No newline at end of file + import { createFluentIcon } from "@fluentui/react-icons"; + const iconPath = "M19.2729..."; // paths scaled to target size 20, from previous step + export const CustomIcon20 = createFluentIcon("CustomIcon20", "20", [iconPath]); + ``` diff --git a/src/reactviews/common/keys.ts b/src/reactviews/common/keys.ts index 152a1ab90a..899fd61f6d 100644 --- a/src/reactviews/common/keys.ts +++ b/src/reactviews/common/keys.ts @@ -11,6 +11,54 @@ export enum KeyCode { ArrowUp = "ArrowUp", ArrowDown = "ArrowDown", Space = "Space", - KeyC = "KeyC", KeyA = "KeyA", + KeyB = "KeyB", + KeyC = "KeyC", + KeyD = "KeyD", + KeyE = "KeyE", + KeyF = "KeyF", + KeyG = "KeyG", + KeyH = "KeyH", + KeyI = "KeyI", + KeyJ = "KeyJ", + KeyK = "KeyK", + KeyL = "KeyL", + KeyM = "KeyM", + KeyN = "KeyN", + KeyO = "KeyO", + KeyP = "KeyP", + KeyQ = "KeyQ", + KeyR = "KeyR", + KeyS = "KeyS", + KeyT = "KeyT", + KeyU = "KeyU", + KeyV = "KeyV", + KeyW = "KeyW", + KeyX = "KeyX", + KeyY = "KeyY", + KeyZ = "KeyZ", + F1 = "F1", + F2 = "F2", + F3 = "F3", + F4 = "F4", + F5 = "F5", + F6 = "F6", + F7 = "F7", + F8 = "F8", + F9 = "F9", + F10 = "F10", + F11 = "F11", + F12 = "F12", + Digit1 = "Digit1", + Digit2 = "Digit2", + Digit3 = "Digit3", + Digit4 = "Digit4", + Digit5 = "Digit5", + Digit6 = "Digit6", + Digit7 = "Digit7", + Digit8 = "Digit8", + Digit9 = "Digit9", + Digit0 = "Digit0", + ContextMenu = "ContextMenu", + Tab = "Tab", } diff --git a/src/reactviews/common/locConstants.ts b/src/reactviews/common/locConstants.ts index 72f37a7e1d..258497e03f 100644 --- a/src/reactviews/common/locConstants.ts +++ b/src/reactviews/common/locConstants.ts @@ -447,7 +447,7 @@ export class LocConstants { message: l10n.t("Message"), openResultInNewTab: l10n.t("Open in New Tab"), showplanXML: l10n.t("Showplan XML"), - showMenu: l10n.t("Show Menu"), + showMenu: l10n.t("Show Menu (F3)"), sortAscending: l10n.t("Sort Ascending"), sortDescending: l10n.t("Sort Descending"), clearSort: l10n.t("Clear Sort"), @@ -517,6 +517,8 @@ export class LocConstants { args: [count], comment: ["{0} is the number of selected rows"], }), + sort: l10n.t("Sort"), + filter: l10n.t("Filter"), }; } diff --git a/src/reactviews/common/utils.ts b/src/reactviews/common/utils.ts index 73f4b85458..f6e9f66950 100644 --- a/src/reactviews/common/utils.ts +++ b/src/reactviews/common/utils.ts @@ -136,3 +136,138 @@ export function isMac(): boolean { export function isMetaKeyPressed(e: KeyboardEvent | MouseEvent | React.KeyboardEvent): boolean { return isMac() ? e.metaKey : e.ctrlKey; } + +/** + * Selector string for focusable elements. + */ +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button", + "textarea", + 'input:not([type="hidden"])', + "select", + "[tabindex]", + '[contenteditable="true"]', +] + .map((s) => `${s}:not([tabindex="-1"])`) + .join(","); + +/** + * Check if an element is visible. + * @param el The element to check. + * @returns True if the element is visible, false otherwise. + */ +function isElementVisible(el: HTMLElement): boolean { + // Covers display:none/visibility:hidden/off-screen containers, etc. + const style = window.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none") return false; + + // offsetParent check misses fixed/absolute in some cases; getClientRects covers that + if (el.offsetParent === null && el.getClientRects().length === 0) return false; + + return true; +} + +/** + * Get all focusable elements within the given root. + * @param root The root element to limit the search within. + * @returns An array of focusable elements. + */ +function getFocusableElements(root: ParentNode = document): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => !el.hasAttribute("disabled") && isElementVisible(el), + ); +} + +/** + * Get the adjacent focusable element in the given direction. + * @param currentElement The current focused element. + * @param step The step direction: 1 for next, -1 for previous. + * @param root The root element to limit the search within. + * @returns The adjacent focusable element, or null if none found. + */ +function getAdjacentFocusableElement( + currentElement: HTMLElement, + step: 1 | -1, + root: ParentNode = document, +): HTMLElement | null { + const focusable = getFocusableElements(root); + if (focusable.length === 0) return null; + + const idx = focusable.indexOf(currentElement); + if (idx === -1) return null; + + const nextIdx = (idx + step + focusable.length) % focusable.length; + return focusable[nextIdx] ?? null; +} + +/** + * Get the next focusable element. + * @param currentElement The current focused element. + * @param root The root element to limit the search within. If not provided, document is used. + * @returns The next focusable element, or null if none found. + */ +export function getNextFocusableElement( + currentElement: HTMLElement, + root?: ParentNode, +): HTMLElement | null { + return getAdjacentFocusableElement(currentElement, 1, root ?? document); +} + +/** + * Get the previous focusable element. + * @param currentElement The current focused element. + * @param root The root element to limit the search within. If not provided, document is used. + * @returns The previous focusable element, or null if none found. + */ +export function getPreviousFocusableElement( + currentElement: HTMLElement, + root?: ParentNode, +): HTMLElement | null { + return getAdjacentFocusableElement(currentElement, -1, root ?? document); +} + +/** + * Get the next focusable element outside the given container. + * @param container The container element to check against. + * @returns The next focusable element outside the container, or null if none found. + */ +export function getNextFocusableElementOutside(container: HTMLElement): HTMLElement | null { + const focusables = getFocusableElements(); + const active = document.activeElement as HTMLElement | null; + if (!active) return null; + + const currentIndex = focusables.findIndex((el) => el === active && container.contains(el)); + if (currentIndex === -1) return null; + + for (let i = currentIndex + 1; i < focusables.length; i++) { + const el = focusables[i]; + if (!container.contains(el)) { + el.focus(); + return el; + } + } + return null; // no next element outside the container +} + +/** + * Get the previous focusable element outside the given container. + * @param container The container element to check against. + * @returns The previous focusable element outside the container, or null if none found. + */ +export function getPreviousFocusableElementOutside(container: HTMLElement): HTMLElement | null { + const focusables = getFocusableElements(); + const active = document.activeElement as HTMLElement | null; + if (!active) return null; + + const currentIndex = focusables.findIndex((el) => el === active && container.contains(el)); + if (currentIndex === -1) return null; + for (let i = currentIndex - 1; i >= 0; i--) { + const el = focusables[i]; + if (!container.contains(el)) { + el.focus(); + return el; + } + } + return null; // no previous element outside the container +} diff --git a/src/reactviews/pages/QueryResult/commandBar.tsx b/src/reactviews/pages/QueryResult/commandBar.tsx index f93020bb67..654687a256 100644 --- a/src/reactviews/pages/QueryResult/commandBar.tsx +++ b/src/reactviews/pages/QueryResult/commandBar.tsx @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Button, makeStyles, Tooltip } from "@fluentui/react-components"; +import { Button, makeStyles, Toolbar, Tooltip } from "@fluentui/react-components"; import { useContext, useState } from "react"; import { QueryResultCommandsContext } from "./queryResultStateProvider"; import { useVscodeWebview2 } from "../../common/vscodeWebviewProvider2"; @@ -26,8 +26,7 @@ import { const useStyles = makeStyles({ commandBar: { - display: "flex", - flexDirection: "column" /* Align buttons vertically */, + width: "16px", }, buttonImg: { display: "block", @@ -109,7 +108,7 @@ const CommandBar = (props: CommandBarProps) => { } return ( -
+ {/* View Mode Toggle */} { title={locConstants.queryResult.saveAsInsert} /> -
+ ); }; diff --git a/src/reactviews/pages/QueryResult/queryResultPane.tsx b/src/reactviews/pages/QueryResult/queryResultPane.tsx index 87bfb71876..de4b8e06c7 100644 --- a/src/reactviews/pages/QueryResult/queryResultPane.tsx +++ b/src/reactviews/pages/QueryResult/queryResultPane.tsx @@ -428,12 +428,18 @@ export const QueryResultPane = () => { }; const restoreResults = (gridRefs: ResultGridHandle[], scrollToGridIndex?: number) => { + const availableHeight = getAvailableHeight( + resultPaneParentRef.current!, + ribbonRef.current!, + ); + const height = calculateGridHeight(gridRefs.length, availableHeight); + const width = calculateGridWidth( + resultPaneParentRef.current!, + gridRefs.length, + availableHeight, + ); + gridRefs.forEach((gridRef) => { - const height = calculateGridHeight( - gridRefs.length, - getAvailableHeight(resultPaneParentRef.current!, ribbonRef.current!), - ); - const width = resultPaneParentRef.current?.clientWidth! - ACTIONBAR_WIDTH_PX; gridRef.resizeGrid(width, height); }); @@ -472,11 +478,17 @@ export const QueryResultPane = () => { return (
- +
+ +
diff --git a/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx b/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx index 23dce5d4ba..8ec0561f84 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx +++ b/src/reactviews/pages/QueryResult/table/plugins/ColumnMenuPopup.tsx @@ -11,6 +11,7 @@ import { Input, InputOnChangeData, Text, + Toolbar, makeStyles, mergeClasses, shorthands, @@ -26,6 +27,7 @@ import { import { useVirtualizer } from "@tanstack/react-virtual"; import { locConstants } from "../../../../common/locConstants"; import { SortProperties } from "../../../../../sharedInterfaces/queryResult"; +import { altShiftOKeyboardShortcut } from "../../../../common/constants"; export type FilterValue = string | undefined; @@ -113,10 +115,10 @@ const useStyles = makeStyles({ columnGap: tokens.spacingHorizontalXS, }, sortButtons: { - display: "flex", - flexDirection: "column", - columnGap: "2px", - "& button": { + width: "100%", + padding: 0, + "> button": { + width: "100%", justifyContent: "flex-start", }, }, @@ -530,7 +532,17 @@ export const ColumnMenuPopup: React.FC = ({ onMouseDown={(e) => e.stopPropagation()} onKeyDown={handleRootKeyDown}>
- Sort + + {locConstants.queryResult.sort} + + {altShiftOKeyboardShortcut} + + -
+
- Filter + {locConstants.queryResult.filter}
{ diff --git a/src/reactviews/pages/QueryResult/table/plugins/GridContextMenu.tsx b/src/reactviews/pages/QueryResult/table/plugins/GridContextMenu.tsx index 0714b2406d..ae59cf0cc2 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/GridContextMenu.tsx +++ b/src/reactviews/pages/QueryResult/table/plugins/GridContextMenu.tsx @@ -7,6 +7,13 @@ import React, { useMemo, useRef } from "react"; import { Menu, MenuList, MenuItem, MenuPopover, MenuTrigger } from "@fluentui/react-components"; import { locConstants } from "../../../../common/locConstants"; import { GridContextMenuAction } from "../../../../../sharedInterfaces/queryResult"; +import { isMac } from "../../../../common/utils"; +import { + cmdAKeyboardShortcut, + cmdCKeyboardShortcut, + ctrlAKeyboardShortcut, + ctrlCKeyboardShortcut, +} from "../../../../common/constants"; export interface GridContextMenuProps { x: number; @@ -58,22 +65,26 @@ export const GridContextMenu: React.FC = ({ }}> e.stopPropagation()} ref={popoverRef}> - onAction(GridContextMenuAction.SelectAll)}> + onAction(GridContextMenuAction.SelectAll)}> {locConstants.queryResult.selectAll} - - - onAction(GridContextMenuAction.CopySelection)}> - {locConstants.queryResult.copy} - - onAction(GridContextMenuAction.CopyWithHeaders)}> - {locConstants.queryResult.copyWithHeaders} - - onAction(GridContextMenuAction.CopyHeaders)}> - {locConstants.queryResult.copyHeaders} - - + onAction(GridContextMenuAction.CopySelection)}> + {locConstants.queryResult.copy} + + onAction(GridContextMenuAction.CopyWithHeaders)}> + {locConstants.queryResult.copyWithHeaders} + + onAction(GridContextMenuAction.CopyHeaders)}> + {locConstants.queryResult.copyHeaders} + {locConstants.queryResult.copyAs} diff --git a/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts index 6a06a43fb8..d3cc8821cd 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts @@ -19,7 +19,11 @@ import { KeyCode } from "../../../../common/keys"; import { QueryResultReactProvider } from "../../queryResultStateProvider"; import { convertDisplayedSelectionToActual } from "../utils"; import { HeaderMenu } from "./headerFilter.plugin"; -import { isMetaKeyPressed } from "../../../../common/utils"; +import { + getNextFocusableElementOutside, + getPreviousFocusableElementOutside, + isMetaKeyPressed, +} from "../../../../common/utils"; export interface ICellSelectionModelOptions { cellRangeSelector?: any; @@ -106,6 +110,12 @@ export class CellSelectionModel this.selector.onBeforeCellRangeSelected, (e: Event, cell: Slick.Cell) => this.handleBeforeCellRangeSelected(e, cell), ); + + this._handler.subscribe(this.grid.onActiveCellChanged, async (_e: Event) => { + if (this.grid.getSelectionModel().getSelectedRanges().length === 0) { + this.handleAfterKeyboardNavigationEvent(); + } + }); } public destroy() { @@ -592,41 +602,96 @@ export class CellSelectionModel } private async handleKeyDown(e: KeyboardEvent): Promise { - const key = e.code; + const keyCode = e.code; const metaOrCtrlPressed = await isMetaKeyPressed(e); + let isHandled = false; + + // Range selection via Shift + Arrow (no Alt, no Meta/Ctrl) + const isArrow = + keyCode === KeyCode.ArrowLeft || + keyCode === KeyCode.ArrowRight || + keyCode === KeyCode.ArrowUp || + keyCode === KeyCode.ArrowDown; + + if (isArrow && e.shiftKey && !e.altKey && !metaOrCtrlPressed) { + this.expandSelection(keyCode); + isHandled = true; + } + + // Open Header menu (F3) + if (keyCode === KeyCode.F3) { + await this.headerFilter?.openMenuForActiveColumn(); + isHandled = true; + } // Select All (Cmd/Ctrl + A) - if (metaOrCtrlPressed && key === KeyCode.KeyA) { - e.preventDefault(); - e.stopPropagation(); + if (metaOrCtrlPressed && keyCode === KeyCode.KeyA) { await this.handleSelectAll(); - return; + isHandled = true; } - // Open Header menu (Alt + F) --- - if (e.altKey && key === KeyCode?.ArrowDown && !e.shiftKey && !metaOrCtrlPressed) { - e.preventDefault(); - e.stopPropagation(); - if ( - this.headerFilter && - typeof this.headerFilter.openMenuForActiveColumn === "function" - ) { - await this.headerFilter.openMenuForActiveColumn(); - } + // Move to first cell of row (Ctrl + left) + if (metaOrCtrlPressed && keyCode === KeyCode.ArrowLeft) { + this.moveToFirstCellInRow(); + isHandled = true; + } + + // Move to last cell of row (Ctrl + right) + if (metaOrCtrlPressed && keyCode === KeyCode.ArrowRight) { + this.moveToLastCellInRow(); + isHandled = true; + } + + // Select current column (Ctrl + space) + if (e.ctrlKey && keyCode === KeyCode.Space) { + this.selectActiveCellColumn(); + isHandled = true; + } + + // Open context menu (Shift + F10) or ContextMenu key + if ((e.shiftKey && keyCode === KeyCode.F10) || keyCode === KeyCode.ContextMenu) { + // Open context menu + // Already handled by onContextMenu event return; } - // Range selection via Shift + Arrow (no Alt, no Meta/Ctrl) - const isArrow = - key === KeyCode.ArrowLeft || - key === KeyCode.ArrowRight || - key === KeyCode.ArrowUp || - key === KeyCode.ArrowDown; + // Select current row (Shift + space) + if (e.shiftKey && keyCode === KeyCode.Space) { + this.selectActiveCellRow(); + isHandled = true; + } + + // Move focus to previous focusable element outside the grid (Shift + Tab) + if (e.shiftKey && keyCode === KeyCode.Tab) { + // Prevent SlickGrid's default Tab behavior and move focus to previous component + e.stopImmediatePropagation(); + await this.moveFocusToOutsideGrid(false); + isHandled = true; + } - if (!isArrow || !e.shiftKey || metaOrCtrlPressed || e.altKey) { - return; // Not our concern—let the default handler run + // Move focus to next focusable element outside the grid (Tab) + if (!e.shiftKey && keyCode === KeyCode.Tab) { + // Prevent SlickGrid's default Tab behavior and move focus to next component + e.stopImmediatePropagation(); + await this.moveFocusToOutsideGrid(true); + isHandled = true; } + // Toggle sort (Shift+Alt+O) + if (e.shiftKey && e.altKey && keyCode === KeyCode.KeyO && !metaOrCtrlPressed) { + await this.toggleSortForActiveCell(); + isHandled = true; + } + + if (isHandled) { + e.preventDefault(); + e.stopPropagation(); + } + } + + private expandSelection( + keyCode: KeyCode.ArrowUp | KeyCode.ArrowDown | KeyCode.ArrowLeft | KeyCode.ArrowRight, + ): void { const active = this.grid.getActiveCell(); if (!active) { return; // Nothing to extend from @@ -655,7 +720,7 @@ export class CellSelectionModel let dCell = last.toCell - last.fromCell; // Nudge the deltas based on the pressed arrow - switch (key) { + switch (keyCode) { case KeyCode.ArrowLeft: dCell -= dirCell; break; @@ -669,7 +734,6 @@ export class CellSelectionModel dRow += dirRow; break; } - // Compute new candidate range const newRange = new Slick.Range( active.row, @@ -689,10 +753,76 @@ export class CellSelectionModel this.grid.scrollRowIntoView(viewRow, false); this.grid.scrollCellIntoView(viewRow, viewCell, false); - // Commit selection and swallow the event this.setSelectedRanges(ranges); - e.preventDefault(); - e.stopPropagation(); + } + + private moveToFirstCellInRow(): void { + const active = this.grid.getActiveCell(); + if (active) { + this.grid.setActiveCell(active.row, 1); + this.grid + .getSelectionModel() + .setSelectedRanges([new Slick.Range(active.row, 1, active.row, 1)]); + } + } + + private moveToLastCellInRow(): void { + const active = this.grid.getActiveCell(); + if (active) { + this.grid.setActiveCell(active.row, this.grid.getColumns().length - 1); + this.grid + .getSelectionModel() + .setSelectedRanges([ + new Slick.Range( + active.row, + this.grid.getColumns().length - 1, + active.row, + this.grid.getColumns().length - 1, + ), + ]); + } + } + + private selectActiveCellColumn(): void { + const active = this.grid.getActiveCell(); + if (active) { + const rowCount = this.grid.getDataLength(); + const newSelectedRange = new Slick.Range(0, active.cell, rowCount - 1, active.cell); + this.setSelectedRanges([newSelectedRange]); + this.grid.setActiveCell(active.row, active.cell); + } + } + + private selectActiveCellRow(): void { + const active = this.grid.getActiveCell(); + if (active) { + const columnCount = this.grid.getColumns().length; + const newSelectedRange = new Slick.Range(active.row, 1, active.row, columnCount - 1); + this.setSelectedRanges([newSelectedRange]); + this.grid.setActiveCell(active.row, active.cell); + } + } + + private async moveFocusToOutsideGrid(forward: boolean): Promise { + const gridContainer = this.grid.getContainerNode(); + if (gridContainer) { + let element: HTMLElement | null = null; + if (forward) { + element = getNextFocusableElementOutside(gridContainer); + } else { + element = getPreviousFocusableElementOutside(gridContainer); + } + if (element) { + element.focus(); + } + } + } + + private async toggleSortForActiveCell(): Promise { + const active = this.grid.getActiveCell(); + if (active && this.headerFilter) { + await this.headerFilter.toggleSortForColumn(active.cell); + } } public async updateSummaryText(ranges?: Slick.Range[]): Promise { diff --git a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts index 5f372767e8..9d3737c32d 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts @@ -163,6 +163,7 @@ export class HeaderMenu { } const buttonEl = $menuButton.get(0); if (buttonEl) { + buttonEl.tabIndex = -1; // Make button focusable but not in tab order this._eventManager.addEventListener(buttonEl, "click", async (e: Event) => { e.stopPropagation(); e.preventDefault(); @@ -247,16 +248,16 @@ export class HeaderMenu { }, onDismiss: () => { this._activeColumnId = null; - this.setFocusToColumn(this._columnDef); + this._grid.focus(); }, onSortAscending: async () => { - await this.applySort(this._columnDef, SortProperties.ASC); + await this.applySort(this._columnDef, SortProperties.ASC, true); }, onSortDescending: async () => { - await this.applySort(this._columnDef, SortProperties.DESC); + await this.applySort(this._columnDef, SortProperties.DESC, true); }, onClearSort: async () => { - await this.handleClearSort(this._columnDef); + await this.handleClearSort(this._columnDef, true); }, currentSort, }); @@ -265,7 +266,11 @@ export class HeaderMenu { this._grid.onHeaderClick.notify(); } - private async applySort(column: FilterableColumn, command: SortProperties) { + private async applySort( + column: FilterableColumn, + command: SortProperties, + skipFocusReset: boolean = false, + ) { const columnId = column.id!; // Clear previous sort state @@ -290,9 +295,15 @@ export class HeaderMenu { } this._columnSortStateMapping.set(columnId, command); - this._currentSortColumn = columnId; - await this.handleMenuItemClick(command, column); + // Update current sort column - clear it if command is NONE + if (command === SortProperties.NONE) { + this._currentSortColumn = ""; + } else { + this._currentSortColumn = columnId; + } + + await this.handleMenuItemClick(command, column, skipFocusReset); const columnFilterState: ColumnFilterState = { columnDef: column.id!, @@ -308,6 +319,7 @@ export class HeaderMenu { } this.onSortChanged.notify(command); + this._grid.focus(); } private getHeaderNode(columnId: string): HTMLElement | null { @@ -334,7 +346,7 @@ export class HeaderMenu { } } - private async handleClearSort(column: FilterableColumn) { + private async handleClearSort(column: FilterableColumn, skipFocusReset: boolean = false) { const columnId = column.id!; // Only clear if this column is currently sorted @@ -348,7 +360,7 @@ export class HeaderMenu { this._currentSortColumn = ""; // Clear sort in grid - await this.handleMenuItemClick(SortProperties.NONE, column); + await this.handleMenuItemClick(SortProperties.NONE, column, skipFocusReset); // Update state const columnFilterState: ColumnFilterState = { @@ -365,6 +377,33 @@ export class HeaderMenu { } } + public async toggleSortForColumn(columnIndex: number): Promise { + const columns = this._grid.getColumns(); + if (columnIndex < 0 || columnIndex >= columns.length) { + return; + } + + const column = columns[columnIndex] as FilterableColumn; + if (!column || column.filterable === false) { + return; + } + + const columnId = column.id!; + const currentSort = this._columnSortStateMapping.get(columnId) ?? SortProperties.NONE; + + // Cycle through: NONE → ASC → DESC → NONE + let nextSort: SortProperties; + if (currentSort === SortProperties.NONE) { + nextSort = SortProperties.ASC; + } else if (currentSort === SortProperties.ASC) { + nextSort = SortProperties.DESC; + } else { + nextSort = SortProperties.NONE; + } + // Skip focus reset to keep the active cell in place + await this.applySort(column, nextSort, true); + } + private async buildFilterItems(): Promise { this._columnDef.filterValues = this._columnDef.filterValues || []; let filterItems: FilterValue[]; @@ -615,7 +654,11 @@ export class HeaderMenu { return [...gridFiltersArray]; } - private async handleMenuItemClick(command: SortProperties, columnDef: Slick.Column) { + private async handleMenuItemClick( + command: SortProperties, + columnDef: Slick.Column, + skipFocusReset: boolean = false, + ) { const dataView = this._grid.getData(); if (command === SortProperties.ASC || command === SortProperties.DESC) { this._grid.setSortColumn(columnDef.id as string, command === SortProperties.ASC); @@ -625,7 +668,7 @@ export class HeaderMenu { await dataView.sort({ grid: this._grid, multiColumnSort: false, - sortCol: this._columnDef, + sortCol: columnDef, sortAsc: command === SortProperties.ASC, }); } else { @@ -643,7 +686,9 @@ export class HeaderMenu { command: command, }); - this.setFocusToColumn(columnDef); + if (!skipFocusReset) { + this.setFocusToColumn(columnDef); + } } private getFilterValues( diff --git a/src/reactviews/pages/QueryResult/table/table.ts b/src/reactviews/pages/QueryResult/table/table.ts index 28798401f9..2afcd79e75 100644 --- a/src/reactviews/pages/QueryResult/table/table.ts +++ b/src/reactviews/pages/QueryResult/table/table.ts @@ -37,7 +37,7 @@ function getDefaultOptions(): Slick.GridOptions { } export const MAX_COLUMN_WIDTH_PX = 400; -export const ACTIONBAR_WIDTH_PX = 36; +export const ACTIONBAR_WIDTH_PX = 30; export const TABLE_ALIGN_PX = 7; export const SCROLLBAR_PX = 15; export const xmlLanguageId = "xml";