From b519717dec461aabcfd270ce91996fff239866fe Mon Sep 17 00:00:00 2001 From: Dave Page Date: Mon, 17 Aug 2026 15:13:01 +0100 Subject: [PATCH 1/2] fix: focus the tree, not its panel, for the Object Explorer shortcut The Object Explorer shortcut, Shift+Alt+B by default, is meant to put the keyboard into the tree so that the arrow keys move between nodes. It called focus() on the rc-dock tab pane wrapping the tree, and that pane is a plain div with no tabindex, so it cannot take focus at all: the shortcut selected a node and left focus wherever it already was, which for anybody navigating by keyboard means it appeared to do nothing. Nothing needs a tabindex adding. The tree that react-aspen renders inside the panel already carries tabindex="-1", so it is focusable programmatically; the fix is to aim at it, falling back to the panel if the tree is not there so the behaviour cannot get worse than it was. Found whilst reviewing #10254, which made this visible: once the Object Explorer can be collapsed, a shortcut that silently fails to focus it is much easier to notice. --- web/pgadmin/browser/static/js/keyboard.js | 7 +- .../browser/keyboard_left_tree_spec.js | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 web/regression/javascript/browser/keyboard_left_tree_spec.js diff --git a/web/pgadmin/browser/static/js/keyboard.js b/web/pgadmin/browser/static/js/keyboard.js index 662b8dd0239..9f8b54b6eab 100644 --- a/web/pgadmin/browser/static/js/keyboard.js +++ b/web/pgadmin/browser/static/js/keyboard.js @@ -209,7 +209,12 @@ _.extend(pgBrowser.keyboardNavigation, { // to give React a chance to paint the panel before we move into it. pgAdmin.Browser.Events.trigger(SHOW_OBJECT_EXPLORER_EVENT); setTimeout(()=>{ - document.querySelector('[id="id-object-explorer"]')?.focus(); + const panel = document.querySelector('[id="id-object-explorer"]'); + // Focus the tree rather than the panel around it. The panel is a plain + // div with no tabindex, so focusing it has never done anything; the + // tree carries tabindex="-1" and can actually take focus, which is + // what makes the arrow keys work once the shortcut has been pressed. + (panel?.querySelector('.file-tree') ?? panel)?.focus(); tree.t.select(tree.i); }, 0); }, diff --git a/web/regression/javascript/browser/keyboard_left_tree_spec.js b/web/regression/javascript/browser/keyboard_left_tree_spec.js new file mode 100644 index 00000000000..31c68ca4854 --- /dev/null +++ b/web/regression/javascript/browser/keyboard_left_tree_spec.js @@ -0,0 +1,84 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2026, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +// keyboard.js reaches pgadmin.js by relative path, which skips the +// sources/pgadmin alias that maps to the fake, so point it there explicitly. +jest.mock('../../../pgadmin/static/js/pgadmin', () => + jest.requireActual('../fake_pgadmin')); + +import pgAdmin from 'sources/pgadmin'; +import '../../../pgadmin/browser/static/js/keyboard'; + +/* The Object Explorer shortcut is meant to put the keyboard into the tree, so + * that the arrow keys move between nodes. It focused the rc-dock tab pane + * around the tree, which is a plain div with no tabindex and therefore cannot + * take focus at all, so the shortcut only ever selected a node and left focus + * wherever it was. */ +describe('keyboardNavigation.bindLeftTree', () => { + let select; + + const buildObjectExplorer = ({withTree = true} = {}) => { + const pane = document.createElement('div'); + pane.id = 'id-object-explorer'; + pane.className = 'dock-tabpane dock-tabpane-active'; + + let tree = null; + if (withTree) { + tree = document.createElement('div'); + tree.className = 'file-tree'; + // As react-aspen renders it: programmatically focusable, not tabbable. + tree.setAttribute('tabindex', '-1'); + pane.appendChild(tree); + } + + document.body.appendChild(pane); + return {pane, tree}; + }; + + beforeEach(() => { + jest.useFakeTimers(); + document.body.innerHTML = ''; + select = jest.fn(); + pgAdmin.Browser.keyboardNavigation.getTreeDetails = () => ({ + t: {select}, i: 'some-tree-item', + }); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('moves focus into the tree', () => { + const {tree} = buildObjectExplorer(); + + pgAdmin.Browser.keyboardNavigation.bindLeftTree(); + jest.runAllTimers(); + + expect(document.activeElement).toBe(tree); + expect(select).toHaveBeenCalledWith('some-tree-item'); + }); + + it('falls back to the panel when there is no tree to focus', () => { + buildObjectExplorer({withTree: false}); + + expect(() => { + pgAdmin.Browser.keyboardNavigation.bindLeftTree(); + jest.runAllTimers(); + }).not.toThrow(); + expect(select).toHaveBeenCalled(); + }); + + it('does not throw when the Object Explorer is not in the DOM', () => { + expect(() => { + pgAdmin.Browser.keyboardNavigation.bindLeftTree(); + jest.runAllTimers(); + }).not.toThrow(); + }); +}); From 86fa7485287b2651d6b5b64256dd62e969cc8d37 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Mon, 17 Aug 2026 15:45:06 +0100 Subject: [PATCH 2/2] Draw the Object Explorer tree's focus indicator ourselves Now that the shortcut actually lands keyboard focus on the tree, the focus indicator becomes something users see routinely, and left to the browser it is an outline-style: auto ring drawn in the host's accent colour. In practice that means it appears orange in one browser and blue in another, and Safari may not draw it at all, so a keyboard user there gets no indication of where focus has gone. Style it with theme.otherVars.activeBorder instead, matching how the dock tabs already indicate focus, so it is consistent across browsers and themes: #326690 on light, #d4d4d4 on dark, #fff on high contrast. The -1px outline offset keeps it inside the scrolling container rather than being clipped at the edges. Verified in the browser: with focus on the tree, the computed outline is "rgb(50, 102, 144) solid 1px" with a -1px offset, in place of the previous "auto" ring. --- .../static/js/Theme/overrides/reactaspen.override.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js b/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js index 2be6e2d188e..a8acfae3d60 100644 --- a/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js +++ b/web/pgadmin/static/js/Theme/overrides/reactaspen.override.js @@ -32,6 +32,17 @@ export default function reactAspenOverride(theme) { display: 'inline-block', position: 'relative', width: '100%', + // The tree carries tabindex="-1" and the Object Explorer shortcut + // focuses it deliberately, so draw the focus indicator ourselves. + // Left to the browser this is an outline-style: auto ring, which takes + // the host's accent colour - orange in one browser, blue in another - + // and Safari may not draw it at all, leaving keyboard users with no + // indication of where focus has landed. The negative offset keeps the + // outline inside the scrolling container so it is not clipped. + '&:focus-visible': { + outline: '1px solid ' + theme.otherVars.activeBorder, + outlineOffset: '-1px', + }, '&, & *': { boxSizing: 'border-box', },