diff --git a/browser/src/control/Control.JSDialog.js b/browser/src/control/Control.JSDialog.js index 45c0689b0493b..04b1bf862cdc7 100644 --- a/browser/src/control/Control.JSDialog.js +++ b/browser/src/control/Control.JSDialog.js @@ -297,8 +297,9 @@ window.L.Control.JSDialog = window.L.Control.extend({ instance.form = window.L.DomUtil.create('form', 'jsdialog-container ui-dialog ui-widget-content lokdialog_container', instance.container); instance.form.setAttribute('role', 'dialog'); - instance.form.setAttribute('aria-labelledby', instance.title); instance.form.setAttribute('autocomplete', 'off'); + if (instance.title) + instance.form.setAttribute('aria-labelledby', instance.title); // Prevent overlay from getting the click, except if we want click to dismiss // Like in the case of the inactivity message. // https://github.com/CollaboraOnline/online/issues/7403 @@ -481,7 +482,7 @@ window.L.Control.JSDialog = window.L.Control.extend({ // this will only search in current instance and not in whole document const tabControlWidget = this.findTabControl(instance); - let focusWidget, firstFocusableElement ; + let focusWidget, firstFocusableElement; if (tabControlWidget && !instance.init_focus_id) { // get DOM element of tabControl from current instance @@ -498,7 +499,7 @@ window.L.Control.JSDialog = window.L.Control.extend({ if (focusables && focusables.length) firstFocusableElement = focusables[0]; } - if (firstFocusableElement && !JSDialog.IsFocusable(firstFocusableElement)){ + if (firstFocusableElement && !JSDialog.IsFocusable(firstFocusableElement)) { firstFocusableElement = JSDialog.FindFocusableWithin(firstFocusableElement, 'next'); } } diff --git a/browser/src/control/jsdialog/Definitions.Types.ts b/browser/src/control/jsdialog/Definitions.Types.ts index 89b8f41b6b165..bb20fe85477a8 100644 --- a/browser/src/control/jsdialog/Definitions.Types.ts +++ b/browser/src/control/jsdialog/Definitions.Types.ts @@ -203,6 +203,8 @@ interface OverflowGroupContainer extends Element { interface GridWidgetJSON extends ContainerWidgetJSON { cols: number; // number of grid columns rows: number; // numer of grid rows + tabIndex?: number; + initialSelectedId?: string; // id of the first selected element } interface ToolboxWidgetJSON extends WidgetJSON { diff --git a/browser/src/control/jsdialog/Util.Dropdown.js b/browser/src/control/jsdialog/Util.Dropdown.js index 7ce8014fcd6c4..9785f6e736c44 100644 --- a/browser/src/control/jsdialog/Util.Dropdown.js +++ b/browser/src/control/jsdialog/Util.Dropdown.js @@ -40,6 +40,7 @@ JSDialog.OpenDropdown = function (id, popupParent, entries, innerCallback, popup allyRole: 'listbox', cols: 1, rows: entries.length, + tabIndex: 0, children: [] } ] @@ -59,7 +60,11 @@ JSDialog.OpenDropdown = function (id, popupParent, entries, innerCallback, popup return false; }; - for (var i in entries) { + const shouldSelectFirstEntry = entries.length > 0 ? !entries.some(entry => entry.selected === true) : false; + + let initialSelectedId; + + for (let i = 0; i < entries.length; i++) { var checkedValue = (entries[i].checked === undefined) ? undefined : (entries[i].uno && isChecked('.uno' + entries[i].uno)); @@ -68,6 +73,7 @@ JSDialog.OpenDropdown = function (id, popupParent, entries, innerCallback, popup if (entries[i].type === 'json') { // replace old grid with new widget json.children[0] = entries[i].content; + initialSelectedId = json.children[0].initialSelectedId; if (json.children[0].type === 'grid') json.gridKeyboardNavigation = true; break; } @@ -121,15 +127,21 @@ JSDialog.OpenDropdown = function (id, popupParent, entries, innerCallback, popup w2icon: entries[i].icon, // FIXME: DEPRECATED icon: entries[i].img, checked: entries[i].checked || checkedValue, - selected: entries[i].selected, + selected: (i === 0 && shouldSelectFirstEntry) ? true : entries[i].selected, hasSubMenu: !!entries[i].items }; + if (entry.selected) initialSelectedId = entry.id; break; } json.children[0].children.push(entry); } + if (initialSelectedId) { + json.init_focus_id = initialSelectedId; + json.children[0].initialSelectedId = initialSelectedId; + } + var lastSubMenuOpened = null; var generateCallback = function (targetEntries) { return function(objectType, eventType, object, data) { diff --git a/browser/src/control/jsdialog/Util.FocusCycle.js b/browser/src/control/jsdialog/Util.FocusCycle.js index 7d49f5eb15f2f..085bff71d4468 100644 --- a/browser/src/control/jsdialog/Util.FocusCycle.js +++ b/browser/src/control/jsdialog/Util.FocusCycle.js @@ -64,6 +64,7 @@ function isFocusable(element) { 'input[type="checkbox"]:not([disabled]):not(.hidden)', 'select:not([disabled]):not(.hidden)', '[tabindex]:not([tabindex="-1"]):not(.jsdialog-begin-marker):not(.jsdialog-end-marker):not([disabled]):not(.hidden)', + '[role="listbox"] [role="option"]:not([disabled]):not(.hidden)' ]; return focusableElements.some((selector) => element.matches(selector)); diff --git a/browser/src/control/jsdialog/Util.KeyboardListNavigation.ts b/browser/src/control/jsdialog/Util.KeyboardListNavigation.ts index ca4cfe54316fb..1d2c720124c66 100644 --- a/browser/src/control/jsdialog/Util.KeyboardListNavigation.ts +++ b/browser/src/control/jsdialog/Util.KeyboardListNavigation.ts @@ -28,15 +28,6 @@ function KeyboardListNavigation( moveToFocusableEntry(currentElement, 'previous'); event.preventDefault(); break; - case 'Tab': - if (event.shiftKey) { - moveToFocusableEntry(currentElement, 'previous'); - event.preventDefault(); - } else { - moveToFocusableEntry(currentElement, 'next'); - event.preventDefault(); - } - break; default: break; } @@ -52,6 +43,13 @@ function moveToFocusableEntry( } }; + const updateAriaActiveDescendant = (elem: HTMLElement) => { + const listbox = elem.closest('[role="listbox"]'); + if (listbox && elem.id) { + listbox.setAttribute('aria-activedescendant', elem.id); + } + }; + // If the current element is focused but not selected, add 'selected' class and return if ( document.activeElement === currentElement && @@ -60,6 +58,7 @@ function moveToFocusableEntry( ) { currentElement.classList.add('selected'); updateAriaSelected(currentElement, 'true'); + updateAriaActiveDescendant(currentElement); return; } @@ -72,6 +71,7 @@ function moveToFocusableEntry( (siblingElement as HTMLElement).focus(); siblingElement.classList.add('selected'); updateAriaSelected(siblingElement, 'true'); + updateAriaActiveDescendant(siblingElement); currentElement.classList.remove('selected'); updateAriaSelected(currentElement, 'false'); @@ -80,6 +80,11 @@ function moveToFocusableEntry( JSDialog.KeyboardListNavigation = function (container: HTMLElement) { container.addEventListener('keydown', (event: KeyboardEvent) => { + const allowedKeys = ['ArrowDown', 'ArrowUp']; + if (!allowedKeys.includes(event.key)) { + return; + } + const activeElement = document.activeElement as HTMLElement; if (!JSDialog.IsTextInputField(activeElement)) { KeyboardListNavigation(event, activeElement); diff --git a/browser/src/control/jsdialog/Widget.Combobox.js b/browser/src/control/jsdialog/Widget.Combobox.js index f72c22f572d75..5ce84ddf4ca19 100644 --- a/browser/src/control/jsdialog/Widget.Combobox.js +++ b/browser/src/control/jsdialog/Widget.Combobox.js @@ -30,6 +30,7 @@ JSDialog.comboboxEntry = function (parentContainer, data, builder) { var entry = window.L.DomUtil.create('div', 'ui-combobox-entry ' + builder.options.cssClass, parentContainer); entry.id = data.id; entry.setAttribute('role', 'option'); + entry.setAttribute('tabindex', '-1'); if (data.hasSubMenu) window.L.DomUtil.addClass(entry, 'ui-has-menu'); @@ -55,6 +56,8 @@ JSDialog.comboboxEntry = function (parentContainer, data, builder) { if (data.selected) { entry.setAttribute('aria-selected', 'true'); window.L.DomUtil.addClass(entry, 'selected'); + } else { + entry.setAttribute('aria-selected', 'false'); } if (data.checked) @@ -79,6 +82,13 @@ JSDialog.comboboxEntry = function (parentContainer, data, builder) { } }); + entry.addEventListener('keydown', function (event) { + if (event.key === 'Tab') { + JSDialog.CloseDropdown(data.comboboxId); + event.preventDefault(); + } + }); + if (data.hasSubMenu) { entry.setAttribute('aria-haspopup', true); entry.setAttribute('aria-expanded', false); diff --git a/browser/src/control/jsdialog/Widget.Containers.ts b/browser/src/control/jsdialog/Widget.Containers.ts index 1c9a45d534ec8..1e2008190ad39 100644 --- a/browser/src/control/jsdialog/Widget.Containers.ts +++ b/browser/src/control/jsdialog/Widget.Containers.ts @@ -70,8 +70,14 @@ JSDialog.grid = function ( if (data.allyRole) { table.role = data.allyRole; + + if (data.allyRole === 'listbox') + table.setAttribute('aria-activedescendant', data.initialSelectedId); } + if (data.tabIndex !== undefined) + table.setAttribute('tabindex', data.tabIndex); + const gridRowColStyle = 'grid-template-rows: repeat(' + rows + diff --git a/browser/src/control/jsdialog/Widget.PageMarginEntry.ts b/browser/src/control/jsdialog/Widget.PageMarginEntry.ts index 1ca8b59be45d2..097e4a3be370a 100644 --- a/browser/src/control/jsdialog/Widget.PageMarginEntry.ts +++ b/browser/src/control/jsdialog/Widget.PageMarginEntry.ts @@ -38,6 +38,7 @@ function createPageMarginEntryWidget(data: any, builder: any): HTMLElement { container.className = 'margins-popup-container'; container.setAttribute('role', 'listbox'); container.setAttribute('aria-label', _('Page margin options')); + container.setAttribute('tabindex', '0'); const lang = window.coolParams.get('lang') || 'en-US'; const useImperial = lang === 'en-US' || lang === 'en'; // we need to consider both short form as some user can user lang=en-US using document URL @@ -56,7 +57,7 @@ function createPageMarginEntryWidget(data: any, builder: any): HTMLElement { return `${formatted}${unit}`; } - const onMarginClick = (evt: MouseEvent) => { + const onMarginClick = (evt: MouseEvent | KeyboardEvent) => { const elm = evt.currentTarget as HTMLElement; const key = elm.id; if (!key || !options[key]) return; @@ -88,17 +89,36 @@ function createPageMarginEntryWidget(data: any, builder: any): HTMLElement { builder.callback('dialog', 'close', { id: data.id }, null); }; - Object.keys(options).forEach((key) => { + Object.keys(options).forEach((key, index) => { const opt = options[key]; + const isFirstItem = index === 0; const item = document.createElement('div'); item.className = 'margin-item'; item.id = key; item.setAttribute('role', 'option'); - item.setAttribute('tabindex', '0'); + item.setAttribute('tabindex', '-1'); item.setAttribute('aria-selected', 'false'); item.addEventListener('click', onMarginClick); + item.addEventListener('keydown', function (event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + onMarginClick(event); + event.preventDefault(); + } else if (event.key === 'Tab') { + JSDialog.CloseDropdown(data.id); + event.preventDefault(); + } + }); + + if (isFirstItem) { + item.classList.add('selected'); + item.setAttribute('aria-selected', 'true'); + container.setAttribute('aria-activedescendant', item.id); + + data.initialSelectedId = item.id; + } + const img = document.createElement('img'); img.className = 'margin-icon'; img.setAttribute('alt', ''); @@ -166,9 +186,20 @@ function createPageMarginEntryWidget(data: any, builder: any): HTMLElement { custom.setAttribute('role', 'button'); custom.setAttribute('tabindex', '0'); - custom.addEventListener('click', (evt: MouseEvent) => { + const customClickEventHdl = () => { map.sendUnoCommand(isCalc ? '.uno:PageFormatDialog' : '.uno:PageDialog'); builder.callback('dialog', 'close', { id: data.id }, null); + }; + + custom.addEventListener('click', customClickEventHdl); + custom.addEventListener('keydown', function (event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + customClickEventHdl(); + event.preventDefault(); + } else if (event.key === 'Tab') { + JSDialog.CloseDropdown(data.id); + event.preventDefault(); + } }); container.appendChild(custom);