MDL-76246 output: combobox support improvements

- It was a mistake to assume the listbox is always within
   combobox.parentElement
 - Take into account that the popup of the combobox is not necessarily a
   listbox
 - Update combobox fix so that it also work with comboboxes that are not
   select-menu
 - Update combobox fix so that it also support editable comboboxes
 - Update click listener to take into account that the event's target
   might be one of the option element
 - Having a hidden input element for comboboxes was not an ARIA
   requirement and was added by us. I added data-input-element to the
   combobox element to specify the input element related to it.
This commit is contained in:
Shamim Rezaie 2023-02-03 00:13:10 +11:00
parent 775872450c
commit c4f33ceb59
4 changed files with 75 additions and 45 deletions

View File

@ -96,6 +96,7 @@
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="{{baseid}}-listbox"
data-input-element="{{baseid}}-input"
tabindex="0"
>
{{selectedoption}}
@ -121,7 +122,7 @@
{{/isgroup}}
{{/options}}
</ul>
<input type="hidden" name="{{name}}" value="{{value}}" />
<input type="hidden" name="{{name}}" value="{{value}}" id="{{baseid}}-input" />
</div>
{{#js}}
var label = document.getElementById('{{baseid}}-label');

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -206,53 +206,60 @@ const comboboxFix = () => {
$(document).on('show.bs.dropdown', e => {
if (e.relatedTarget.matches('[role="combobox"]')) {
const combobox = e.relatedTarget;
const listbox = combobox.parentElement.querySelector('[role="listbox"]');
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
// To make sure ArrowDown doesn't move the active option afterwards.
setTimeout(() => {
if (selectedOption) {
selectedOption.classList.add('active');
combobox.setAttribute('aria-activedescendant', selectedOption.id);
} else {
const firstOption = listbox.querySelector('[role="option"]');
firstOption.setAttribute('aria-selected', 'true');
firstOption.classList.add('active');
combobox.setAttribute('aria-activedescendant', firstOption.id);
}
}, 0);
if (listbox) {
const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
// To make sure ArrowDown doesn't move the active option afterwards.
setTimeout(() => {
if (selectedOption) {
selectedOption.classList.add('active');
combobox.setAttribute('aria-activedescendant', selectedOption.id);
} else {
const firstOption = listbox.querySelector('[role="option"]');
firstOption.setAttribute('aria-selected', 'true');
firstOption.classList.add('active');
combobox.setAttribute('aria-activedescendant', firstOption.id);
}
}, 0);
}
}
});
$(document).on('hidden.bs.dropdown', e => {
if (e.relatedTarget.matches('[role="combobox"]')) {
const combobox = e.relatedTarget;
const listbox = combobox.parentElement.querySelector('[role="listbox"]');
const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
combobox.removeAttribute('aria-activedescendant');
setTimeout(() => {
// Undo all previously highlighted options.
listbox.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
}, 0);
if (listbox) {
setTimeout(() => {
// Undo all previously highlighted options.
listbox.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
}, 0);
}
}
});
// Handling keyboard events for both navigating through and selecting options.
document.addEventListener('keydown', e => {
if (e.target.matches('.select-menu [role="combobox"]')) {
if (e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')) {
const combobox = e.target;
const trigger = e.key;
let next = null;
const options = combobox.parentElement.querySelectorAll('[role="listbox"] [role="option"]');
const activeOption = combobox.parentElement.querySelector('[role="listbox"] .active[role="option"]');
const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
const options = listbox.querySelectorAll('[role="option"]');
const activeOption = listbox.querySelector('.active[role="option"]');
const editable = combobox.hasAttribute('aria-autocomplete');
// Under the special case that the dropdown menu is being shown as a result of they key press (like when the user
// Under the special case that the dropdown menu is being shown as a result of the key press (like when the user
// presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.
// It's because of a race condition with show.bs.dropdown event handler.
if (options && activeOption) {
if (options && (activeOption || editable)) {
if (trigger == 'ArrowDown') {
for (let i = 0; i < options.length - 1; i++) {
if (options[i] == activeOption) {
@ -260,6 +267,9 @@ const comboboxFix = () => {
break;
}
}
if (editable && !next) {
next = options[0];
}
} if (trigger == 'ArrowUp') {
for (let i = 1; i < options.length; i++) {
if (options[i] == activeOption) {
@ -267,13 +277,17 @@ const comboboxFix = () => {
break;
}
}
if (editable && !next) {
next = options[options.length - 1];
}
} else if (trigger == 'Home') {
next = options[0];
} else if (trigger == 'End') {
next = options[options.length - 1];
} else if (trigger == ' ' || trigger == 'Enter') {
} else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
e.preventDefault();
selectOption(combobox, activeOption);
} else {
} else if (!editable) {
// Search for options by finding the first option that has
// text starting with the typed character (case insensitive).
for (let i = 0; i < options.length; i++) {
@ -290,27 +304,33 @@ const comboboxFix = () => {
// Variable next is set if we do want to act on the keypress.
if (next) {
e.preventDefault();
activeOption.classList.remove('active');
if (activeOption) {
activeOption.classList.remove('active');
}
next.classList.add('active');
combobox.setAttribute('aria-activedescendant', next.id);
next.scrollIntoView({block: 'nearest'});
}
}
}
});
document.addEventListener('click', e => {
if (e.target.matches('.select-menu [role="option"]')) {
const option = e.target;
const combobox = option.closest('.select-menu').querySelector('[role="combobox"]');
combobox.focus();
selectOption(combobox, option);
const option = e.target.closest('[role="listbox"] [role="option"]');
if (option) {
const listbox = option.closest('[role="listbox"]');
const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
if (combobox) {
combobox.focus();
selectOption(combobox, option);
}
}
});
// In case some code somewhere else changes the value of the combobox.
document.addEventListener('change', e => {
if (e.target.matches('.select-menu input[type="hidden"]')) {
const combobox = e.target.parentElement.querySelector('[role="combobox"]');
if (e.target.matches('input[type="hidden"][id]')) {
const combobox = document.querySelector(`[role="combobox"][data-input-element="${e.target.id}"]`);
const option = e.target.parentElement.querySelector(`[role="option"][data-value="${e.target.value}"]`);
if (combobox && option) {
@ -320,8 +340,8 @@ const comboboxFix = () => {
});
const selectOption = (combobox, option) => {
const oldSelectedOption = combobox.parentElement.querySelector('[role="listbox"] [role="option"][aria-selected="true"]');
const inputElement = combobox.parentElement.querySelector('input[type="hidden"]');
const listbox = option.closest('[role="listbox"]');
const oldSelectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
if (oldSelectedOption != option) {
if (oldSelectedOption) {
@ -329,10 +349,19 @@ const comboboxFix = () => {
}
option.setAttribute('aria-selected', 'true');
}
combobox.textContent = option.textContent;
if (inputElement.value != option.dataset.value) {
inputElement.value = option.dataset.value;
inputElement.dispatchEvent(new Event('change', {bubbles: true}));
if (combobox.hasAttribute('value')) {
combobox.value = option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
} else {
combobox.textContent = option.textContent;
}
if (combobox.dataset.inputElement) {
const inputElement = document.getElementById(combobox.dataset.inputElement);
if (inputElement && (inputElement.value != option.dataset.value)) {
inputElement.value = option.dataset.value;
inputElement.dispatchEvent(new Event('change', {bubbles: true}));
}
}
};
};