diff --git a/browser/src/control/jsdialog/Widget.TreeView.ts b/browser/src/control/jsdialog/Widget.TreeView.ts index 364193d8be30d..bf66b35a8cde8 100644 --- a/browser/src/control/jsdialog/Widget.TreeView.ts +++ b/browser/src/control/jsdialog/Widget.TreeView.ts @@ -367,6 +367,9 @@ class TreeViewControl { parent, ); this._rows.set(String(entry.row), tr); + + //id is needed to find the element to regain focus after widget is updated. see updateWidget in Control.JSDialogBuilder.js + tr.id = data.id + '_' + entry.row; tr.setAttribute('level', String(level)); (tr as any)._row = entry.row; const rowRole = @@ -1012,6 +1015,11 @@ class TreeViewControl { return; } + // Remember if the focused element is inside this treeview, + // because clearing selections removes tabindex which drops + // focus to BODY for non-natively-focusable elements. + const hadFocus = this._container.contains(document.activeElement); + // Clear existing selections this._container .querySelectorAll('.ui-treeview-entry.selected') @@ -1021,7 +1029,7 @@ class TreeViewControl { // Select the target row const checkbox = rowElement.querySelector('input') as HTMLInputElement; - this.selectEntry(rowElement, checkbox, shouldFocus); + this.selectEntry(rowElement, checkbox, shouldFocus || hadFocus); } unselectEntry(item: HTMLElement) { @@ -1363,7 +1371,17 @@ class TreeViewControl { var nextElement = listElements.at(toIndex); nextElement.tabIndex = 0; nextElement.focus(); + (builder as any).callback( + 'treeview', + 'select', + data, + (nextElement as any)._row, + builder, + ); + // Update tabindex so the new entry is in the tab order and the + // old one is removed. Selected entries keep their tabindex so + // they remain reachable via Tab. var nextInput = Array.from( listElements .at(toIndex) @@ -1384,14 +1402,6 @@ class TreeViewControl { ) as Array; if (oldInput && oldInput.length) oldInput.at(0).tabIndex = -1; } - - (builder as any).callback( - 'treeview', - 'select', - data, - (nextElement as any)._row, - builder, - ); } getCurrentEntry(listElements: Array) { diff --git a/cypress_test/integration_tests/common/helper.js b/cypress_test/integration_tests/common/helper.js index 32251a62ee3a2..54a7e2e20d8ae 100644 --- a/cypress_test/integration_tests/common/helper.js +++ b/cypress_test/integration_tests/common/helper.js @@ -1338,6 +1338,36 @@ function waitForMapState(command, expectedValue) { cy.log('<< waitForMapState - end'); } +// cy.realPress('Enter') via CDP bypasses preventDefault on keydown, +// which causes implicit form submission and spurious button clicks +// that don't happen with real keyboard input. This helper blocks +// those side effects for a single keypress. +function realPressInDialog(key) { + cy.cGet('.jsdialog-window form').then($form => { + // Block implicit form submission for this keypress + function blockSubmit(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + } + $form[0].addEventListener('submit', blockSubmit, true); + + // Block the close button from being clicked + var closeBtn = $form[0].querySelector('.ui-dialog-titlebar-close'); + var origOnclick = closeBtn ? closeBtn.onclick : null; + if (closeBtn) { + closeBtn.onclick = function(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + } + + cy.realPress(key).then(() => { + $form[0].removeEventListener('submit', blockSubmit, true); + if (closeBtn) closeBtn.onclick = origOnclick; + }); + }); +} + module.exports.setupDocument = setupDocument; module.exports.loadDocument = loadDocument; module.exports.setupAndLoadDocument = setupAndLoadDocument; @@ -1395,6 +1425,7 @@ module.exports.waitForTimers = waitForTimers; module.exports.waitForMapState = waitForMapState; module.exports.maxScreenshotableViewportHeight = maxScreenshotableViewportHeight; module.exports.getMatchedCSSRules = getMatchedCSSRules; +module.exports.realPressInDialog = realPressInDialog; // Find all author CSS rules that match an element, with the source // stylesheet filename and the full selector for each matching rule. diff --git a/cypress_test/integration_tests/desktop/writer/a11y_dialog_spec.js b/cypress_test/integration_tests/desktop/writer/a11y_dialog_spec.js index b0bc113d7ce2e..22d4bdbd8e563 100644 --- a/cypress_test/integration_tests/desktop/writer/a11y_dialog_spec.js +++ b/cypress_test/integration_tests/desktop/writer/a11y_dialog_spec.js @@ -187,6 +187,83 @@ describe(['tagdesktop'], 'Accessibility Writer Dialog Tests', { testIsolation: f }); }); + it('Treeview Enter key keeps focus in dialog', function () { + cy.then(function () { + win.app.map.sendUnoCommand('.uno:ChapterNumberingDialog'); + }); + + a11yHelper.getActiveDialog(1) + .then(() => helper.processToIdle(win)) + .then(() => { + cy.cGet('#level .ui-treeview-entry:nth-child(1)').click(); + return helper.processToIdle(win); + }) + .then(() => { + cy.cGet('#level .ui-treeview-entry:nth-child(1)').should('have.class', 'selected'); + cy.cGet('#level .ui-treeview-entry:nth-child(1)').focus(); + + // Move to second entry + cy.realPress('ArrowDown'); + return helper.processToIdle(win); + }) + .then(() => { + cy.cGet('#level .ui-treeview-entry:nth-child(2)').should('have.class', 'selected'); + cy.cGet('#level .ui-treeview-entry:nth-child(2)').should('have.focus'); + + // Press Enter on the focused entry + helper.realPressInDialog('Enter'); + return helper.processToIdle(win); + }) + .then(() => { + // Either dialog should still exist with focus inside it, + // or dialog should have closed (OK activated) + cy.cGet('body').then($body => { + const dialogExists = $body.find('.ui-dialog[role="dialog"]').length > 0; + if (dialogExists) { + // Dialog still open - focus must be inside it + cy.cGet('.ui-dialog[role="dialog"]').then($dlg => { + const activeEl = win.document.activeElement; + const focusInDialog = $dlg[0].contains(activeEl); + const focusDesc = activeEl.tagName + '#' + activeEl.id + '.' + activeEl.className; + expect(focusInDialog, 'focus should stay in dialog, but is on: ' + focusDesc).to.be.true; + }); + cy.cGet('#cancel-button').click(); + } else { + cy.log('Dialog closed after Enter'); + } + }); + }); + }); + + it('Treeview arrow key moves focus and selection together', function () { + cy.then(function () { + win.app.map.sendUnoCommand('.uno:ChapterNumberingDialog'); + }); + + a11yHelper.getActiveDialog(1) + .then(() => helper.processToIdle(win)) + .then(() => { + // Click to select, then focus the entry for keyboard navigation + cy.cGet('#level .ui-treeview-entry:nth-child(1)').click(); + return helper.processToIdle(win); + }) + .then(() => { + cy.cGet('#level .ui-treeview-entry:nth-child(1)').should('have.class', 'selected'); + cy.cGet('#level .ui-treeview-entry:nth-child(1)').focus(); + + // ArrowDown should move both focus and selection to the second entry + cy.realPress('ArrowDown'); + return helper.processToIdle(win); + }) + .then(() => { + cy.cGet('#level .ui-treeview-entry:nth-child(2)').should('have.class', 'selected'); + cy.cGet('#level .ui-treeview-entry:nth-child(2)').should('have.focus'); + cy.cGet('#level .ui-treeview-entry:nth-child(1)').should('not.have.class', 'selected'); + + a11yHelper.closeActiveDialog(1); + }); + }); + a11yHelper.allCommonDialogs.forEach(function (commandSpec) { const command = typeof commandSpec === 'string' ? commandSpec : commandSpec.command; if (excludedCommonDialogs.includes(command)) {