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
28 changes: 19 additions & 9 deletions browser/src/control/jsdialog/Widget.TreeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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')
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -1384,14 +1402,6 @@ class TreeViewControl {
) as Array<HTMLElement>;
if (oldInput && oldInput.length) oldInput.at(0).tabIndex = -1;
}

(builder as any).callback(
'treeview',
'select',
data,
(nextElement as any)._row,
builder,
);
}

getCurrentEntry(listElements: Array<HTMLElement>) {
Expand Down
31 changes: 31 additions & 0 deletions cypress_test/integration_tests/common/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 77 additions & 0 deletions cypress_test/integration_tests/desktop/writer/a11y_dialog_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
Loading