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/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', }, 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(); + }); +});