From 780a5124ae160834a0c9141fe0ac433d5ba77a58 Mon Sep 17 00:00:00 2001 From: "Grigorii K. Shartsev" Date: Fri, 2 Feb 2024 21:40:27 +0100 Subject: [PATCH] fix(NcRichContenteditable): make autocomplete accessible Signed-off-by: Grigorii K. Shartsev --- .../NcRichContenteditable.vue | 98 +++++++++++++++++-- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/src/components/NcRichContenteditable/NcRichContenteditable.vue b/src/components/NcRichContenteditable/NcRichContenteditable.vue index 561a107cb2..144b5669e1 100644 --- a/src/components/NcRichContenteditable/NcRichContenteditable.vue +++ b/src/components/NcRichContenteditable/NcRichContenteditable.vue @@ -230,6 +230,11 @@ export default { aria-multiline="true" class="rich-contenteditable__input" role="textbox" + aria-haspopup="listbox" + aria-autocomplete="inline" + :aria-controls="tributeId" + :aria-expanded="isAutocompleteOpen ? 'true' : 'false'" + :aria-activedescendant="autocompleteActiveId" v-bind="$attrs" v-on="listeners" @focus="moveCursorToEnd" @@ -241,6 +246,8 @@ export default { @keydown.ctrl.enter.exact.stop.prevent="onCtrlEnter" @paste="onPaste" @keyup.stop.prevent.capture="onKeyUp" + @keydown.up.exact.stop="onTributeArrowKeyDown" + @keydown.down.exact.stop="onTributeArrowKeyDown" @tribute-active-true="onTributeActive(true)" @tribute-active-false="onTributeActive(false)" />
`
${content}
` + const tributesCollection = [] tributesCollection.push({ // Allow spaces in the middle of mentions @@ -544,7 +565,7 @@ export default { // Where to inject the menu popup menuContainer: this.menuContainer, // Popup mention autocompletion templates - menuItemTemplate: item => this.renderComponentHtml(item.original, NcAutoCompleteResult), + menuItemTemplate: item => renderMenuItem(this.renderComponentHtml(item.original, NcAutoCompleteResult)), // Hide if no results noMatchTemplate: () => '', // Inner display of mentions @@ -568,8 +589,7 @@ export default { // instead of trying to show an image and their name. return item.original } - - return `${item.original.native} :${item.original.short_name}` + return renderMenuItem(`${item.original.native} :${item.original.short_name}`) }, // Hide if no results noMatchTemplate: () => t('No emoji found'), @@ -613,7 +633,7 @@ export default { // Where to inject the menu popup menuContainer: this.menuContainer, // Popup mention autocompletion templates - menuItemTemplate: item => ` ${item.original.title}`, + menuItemTemplate: item => renderMenuItem(` ${item.original.title}`), // Hide if no results noMatchTemplate: () => t('No link provider found'), selectTemplate: this.getLink, @@ -859,11 +879,23 @@ export default { return this.tribute.menu }, + /** + * Get the currently selected item element id in Tribute.js container + * @return {HTMLElement} + */ + getTributeSelectedItem() { + // Tribute does not provide a way to get the active item, only the data index + // So we have to find it manually by select class + return this.getTributeContainer().querySelector('.highlight [id^="tribute-item-"]') + }, + /** * Handle Tribute activation * @param {boolean} isActive - is active */ onTributeActive(isActive) { + this.isAutocompleteOpen = isActive + if (isActive) { // Tribute.js doesn't support containerClass update when new collection is open // The first opened collection's containerClass stays forever @@ -872,12 +904,66 @@ export default { // So we have to manually update the class // The default class is "tribute-container" this.getTributeContainer().setAttribute('class', this.tribute.current.collection.containerClass || 'tribute-container') + + this.setupTributeIntegration() } else { // Cancel loading data for autocomplete // Otherwise it could be received when another autocomplete is already opened this.debouncedAutoComplete.clear() + + // Reset active item + this.autocompleteActiveId = undefined } }, + + onTributeArrowKeyDown() { + if (!this.isAutocompleteOpen) { + return + } + this.onTributeSelectedItemWillChange() + }, + + onTributeSelectedItemWillChange() { + // Wait until tribute has updated the selected item + requestAnimationFrame(() => { + this.autocompleteActiveId = this.getTributeSelectedItem()?.id + }) + }, + + setupTributeIntegration() { + if (this.isTributeIntegrationDone) { + return + } + + const tributeContainer = this.getTributeContainer() + + // For aria-controls + tributeContainer.id = this.tributeId + + // Container with options must be a listbox + tributeContainer.setAttribute('role', 'listbox') + // Reset list+listitem role from ul+li + const ul = tributeContainer.children[0] + ul.setAttribute('role', 'presentation') + + // Tribute.js does not provide a way to react on show/hide + // tribute-active-true/false events are fired on initial activation, which is too early with async autoComplete function + this.tributeStyleMutationObserver = new MutationObserver(([{ target }]) => { + if (target.style.display !== 'none') { + // Tribute is visible - there will be selected item + this.onTributeSelectedItemWillChange() + } + }).observe(tributeContainer, { + attributes: true, + attributeFilter: ['style'], + }) + + // Handle selecting new item on mouse selection + tributeContainer.addEventListener('mousemove', () => { + this.onTributeSelectedItemWillChange() + }, { passive: true }) + }, + }, }