Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion web/pgadmin/browser/static/js/keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,16 @@ _.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.
// There is deliberately no fallback to the panel: it cannot take focus,
// and giving it a tabindex purely to catch this case would land the
// keyboard on a container that handles no keys, which is worse for
// anyone navigating by keyboard than leaving focus where it was.
panel?.querySelector('.file-tree')?.focus();
tree.t.select(tree.i);
}, 0);
},
Expand Down
11 changes: 11 additions & 0 deletions web/pgadmin/static/js/Theme/overrides/reactaspen.override.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
92 changes: 92 additions & 0 deletions web/regression/javascript/browser/keyboard_left_tree_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/////////////////////////////////////////////////////////////
//
// 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('leaves focus alone when there is no tree to focus', () => {
const {pane} = buildObjectExplorer({withTree: false});
const elsewhere = document.createElement('button');
document.body.appendChild(elsewhere);
elsewhere.focus();

expect(() => {
pgAdmin.Browser.keyboardNavigation.bindLeftTree();
jest.runAllTimers();
}).not.toThrow();
// The panel cannot take focus and is not given a tabindex to make it
// able to, so focus stays where the user left it rather than landing on
// a container that handles no keys.
expect(document.activeElement).toBe(elsewhere);
expect(document.activeElement).not.toBe(pane);
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();
});
});
Loading