Skip to content

Commit

Permalink
XWIKI-22516: CKEditor inplace editing focus-unfocus ends up in an inc…
Browse files Browse the repository at this point in the history
…orrect state for the editor accessibility

(cherry picked from commit 80eb661)
  • Loading branch information
mflorea committed Dec 13, 2024
1 parent d7636b1 commit 6964298
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,37 @@
});

function maybeUpdateFocusedPlaceholder(editor) {
const oldFocusedEmptyBlock = editor.editable()?.findOne("[" + ATTRIBUTE_NAME + "]");
const editable = editor.editable();
const oldFocusedEmptyBlock = editable?.findOne("[" + ATTRIBUTE_NAME + "]");
const newFocusedEmptyBlock = getFocusedEmptyBlock(editor);
if (newFocusedEmptyBlock !== oldFocusedEmptyBlock) {
// The placeholder update shouldn't generate a separate entry in the editing history. Instead, we want to
// update the previous or the next history entry. This way, undo should restore the state before the
// placeholder update.
editor.fire('lockSnapshot');
oldFocusedEmptyBlock?.removeAttribute(ATTRIBUTE_NAME);
newFocusedEmptyBlock?.setAttribute(ATTRIBUTE_NAME, getPlaceholderContent(newFocusedEmptyBlock?.getName()));
let placeholderContent = editor.config.editorplaceholder;
if (newFocusedEmptyBlock) {
placeholderContent = getPlaceholderContent(newFocusedEmptyBlock.getName());
newFocusedEmptyBlock.setAttribute(ATTRIBUTE_NAME, placeholderContent);
}
if (editable?.isInline()) {
editable.setAttribute('aria-placeholder', placeholderContent);
}
editor.fire('unlockSnapshot');
}
}

editor.on('beforeDestroy', function() {
var editable = editor.editable();
if (editable?.isInline()) {
editable.removeAttributes([
'aria-readonly',
'aria-placeholder'
]);
}
});

/**
* Checks if the editor is focused and the element that has the caret is empty.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,17 +250,20 @@
originalAutoCompletePrototype.attach.apply(this, arguments);
this.view.element.setAttribute('aria-label', this.view.editor.localization.get('xwiki-slash.dropdown.hint'));
},

open: function() {
originalAutoCompletePrototype.open.apply(this, arguments);
// Include the query in the view so that we can properly wait for the auto-complete drop-down in our integration
// tests.
this.view.element.setAttribute('data-query', this.model.query);
},

getHtmlToInsert: function(...args) {
return withStrictHTMLEncoding(() => {
return originalAutoCompletePrototype.getHtmlToInsert.apply(this, args);
});
},

getTextWatcher: function(...args) {
// When pressing enter to select a shortcut Quick Action, the shortcut's textWatcher catches the event
// and opens the new drop-down. It however doesn't catch click events by default, which is an other
Expand All @@ -269,6 +272,66 @@
const textWatcher = originalAutoCompletePrototype.getTextWatcher.apply(this, args);
this.editor.on("afterInsertHtml", function () {textWatcher.check(false);});
return textWatcher;
},

/**
* We overwrite the default implementation because:
* - it sets the {@code aria-controls} attribute to the id of the current AutoComplete instance, loosing the
* previous values (we should keep all the values, space separated)
* - it sets the {@code aria-expanded} attribute which is not supported by the {@code textbox} role; we should use
* instead the {@code aria-haspopup} attribute
*/
addAriaAttributesToEditable: function() {
const editable = this.editor.editable();
const autocompleteId = this.view.element.getAttribute('id');
if (editable?.isInline()) {
// The textbox role supports autocompletion.
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/textbox_role#associated_aria_properties
editable.setAttribute('aria-autocomplete', 'list');
// The editor can have multiple AutoComplete instances (e.g. quick actions, mentions, links, etc.), each with
// their own dropdown. We need to list all of them (it's fine if the dropdowns are currently hidden).
editable.setAttribute('aria-controls', `${editable.getAttribute('aria-controls') || ''} ${autocompleteId}`
.trim());
// The autocomplete dropdown is hidden by default. We'll set this to true when we have suggestions to show.
// See #updateAriaAttributesOnEditable
editable.setAttribute('aria-haspopup', 'false');
// We'll use this to indicate the selected (active) suggestion.
// See #updateAriaActiveDescendantAttributeOnEditable
editable.setAttribute('aria-activedescendant', '');
}
},

/**
* We overwrite the default implementation because it sets the {@code aria-expanded} attribute which is not
* supported by the {@code textbox} role. We have to use the {@code aria-haspopup} attribute instead.
*
* @param {CKEDITOR.event} event the {@code change-isActive} event triggered by the model
*/
updateAriaAttributesOnEditable: function(event) {
const editable = this.editor.editable();
const isActive = event.data;
if (editable?.isInline()) {
editable.setAttribute('aria-haspopup', isActive ? 'true' : 'false');
if (!isActive) {
editable.setAttribute('aria-activedescendant', '');
}
}
},

/**
* We overwrite the default implementation in order to remove the {@code aria-autocomplete} and
* {@code aria-haspopup} attributes.
*/
removeAriaAttributesFromEditable: function() {
var editable = this.editor.editable();
if (editable?.isInline()) {
editable.removeAttributes([
'aria-autocomplete',
'aria-controls',
'aria-haspopup',
'aria-activedescendant'
]);
}
}
});

Expand All @@ -282,6 +345,14 @@
args[0] = CSS.escape(args[0]);
return originalViewPrototype.getItemById.apply(this, args);
},

createElement: function(...args) {
const element = originalViewPrototype.createElement.apply(this, args);
// WCAG: Scrollable region must have keyboard access
element.setAttribute('tabindex', '0');
return element;
},

createItem: function(...args) {
return withStrictHTMLEncoding(() => {
return originalViewPrototype.createItem.apply(this, args);
Expand Down Expand Up @@ -434,6 +505,15 @@
'</li>'
].join('');
this.view.groupTemplate = new CKEDITOR.template(groupTemplate);

// The base implementation performs the cleanup on the 'destroy' event, which is triggered after the editable is
// destroyed so it's too late for us to remove the aria attributes that were added in the initialization phase. We
// need to perform the cleanup before the editable is destroyed.
editor.on('beforeDestroy', function() {
this.destroy();
// The 'destroy' event listener has not been removed so we make sure it doesn't do anything.
this.destroy = function() {};
}, this);
};
// Inherit the autocomplete methods.
AdvancedAutoComplete.prototype = CKEDITOR.tools.prototypedCopy(AutoComplete.prototype);
Expand Down

0 comments on commit 6964298

Please sign in to comment.