Description
When using suneditor instances on bootstrap 5.3 modals an aria warning is triggered when the modal is closed:
Blocked aria-hidden on an element because its descendant retained focus. The focus must not be hidden from assistive technology users. Avoid using aria-hidden on a focused element or its ancestor. Consider using the inert attribute instead, which will also prevent focus. For more details, see the aria-hidden section of the WAI-ARIA specification at https://w3c.github.io/aria/#aria-hidden.
Element with focus: <div.se-wrapper-inner se-wrapper-wysiwyg sun-editor-editable>
To Reproduce
Steps to reproduce the behavior:
create a boostrap modal with a textarea and instantiate suneditor
Expected behavior
Modal closes without aria warning
Desktop (please complete the following information):
- OS: [e.g. iOS] Ubuntu 25.04
- Browser [e.g. chrome, safari] Chrome
- Version [e.g. 22] 136.0.7103.113 (Official Build) (64-bit)
Additional context
I am using suneditor with flask-wtf textareafield, e.g.
resolution_notes = TextAreaField(
id="resolution-notes",
render_kw={"class": "editor"}
)
which is rendered in the bootstrap 5.3 modal (jina2 template) like so:
<div class="modal fade" id="resolution-modal" data-bs-backdrop="static" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content h-auto">
<div class="modal-header">
<h5 class="modal-title">Resolution Notes</h5>
</div>
<div class="modal-body">
{{ render_field(form.resolution_code_id) }}
<hr>
{{ form.resolution_notes }}
</div>
<div class="modal-footer">
<button id="modal-cancel-resolution" type="button" class="btn btn-danger live">Cancel</button>
<button id="modal-add-resolution-btn" type="button" class="btn btn-primary live">Add Resolution</button>
</div>
</div>
</div>
</div>
This is my script for managing suneditor instances:
import {showFormButtons, showSwal} from './form-classes/form-utils.js';
class SunEditorClass {
constructor(containerIds, sunEditorOptions) {
this.status = document.getElementById('status');
this.sunEditorOptions = sunEditorOptions;
this.ticketNumber = document.getElementById('ticket-number');
this.ticketType = document.getElementById('ticket-type');
this.normalButtonList = [
// default button lists can be set for different screen sizes
['fullScreen'],
['fontSize'],
['formatBlock'],
['bold', 'underline', 'italic'],
['fontColor', 'hiliteColor'],
['removeFormat'],
['outdent', 'indent'],
['align', 'horizontalRule', 'list'],
['table', 'image'],
];
this.closedOptions = {
buttonList: [...this.normalButtonList],
width: '100%',
resizeEnable: false,
resizingBar: false,
};
this.timelineOptions = {
autoResize: true,
buttonList: [['saveBtn'], ...this.normalButtonList],
fontSize: [
12,
14,
16,
24,
],
defaultStyle: 'font-size: 12pt;',
fontSizeUnit: 'pt',
hideToolbar: true,
minHeight: '250px',
maxHeight: '600px',
plugins: [this.saveBtnPlugin], // Add the plugin
resizeEnable: false,
resizingBar: false,
showPathLabel: false,
width: '100%',
}
this.normalOptions = {
height: 'auto',
minHeight: '250px',
autoResize: true,
width: '100%',
fontSize: [
12,
14,
16,
24,
],
defaultStyle: 'font-size: 12pt;',
fontSizeUnit: 'pt',
fullScreenOffset: 150,
buttonList: this.normalButtonList,
imageAccept: '.jpg, .png, .gif',
};
// Map to store sunEditor instances, similar to tomSelectInstances
this.sunEditorInstances = new Map();
this.journalInstances = new Map(); // Map to store journal sunEditor instances
}
createSunEditor(id) {
const area = document.getElementById(id);
let options;
if (area.classList.contains('timeline-editor')) {
const saveBtnPlugin = {
name: 'saveBtn',
display: 'command',
innerHTML: '<i class="bi bi-floppy text-bg-success"></i>',
buttonClass: '',
add: function (core, targetElement) {
const context = core.context;
context.saveBtn = context.saveBtn || {};
context.saveBtn.targetButton = targetElement;
context.saveBtn.editorId = id; // Capture the current editor's ID
setTimeout(() => {
new bootstrap.Tooltip(targetElement, {
title: 'Save Changes',
placement: 'bottom',
});
}, 100);
},
action: () => { // Use arrow function to bind to the class instance
this.saveNote(id); // Pass the current editor's ID
}
};
options = {...this.timelineOptions};
options.buttonList = [['saveBtn'], ...this.normalButtonList];
options.plugins = [saveBtnPlugin];
} else {
options = {...this.normalOptions};
}
if (this.status) {
if (this.status.value === 'closed') {
options = {...this.closedOptions};
}
}
if (this.sunEditorInstances.has(id)) {
return; // editor already exists
}
const editorInstance = SUNEDITOR.create(id, options);
// Store the instance in the map, using the ID as the key
this.sunEditorInstances.set(id, editorInstance);
this.setupListeners(area);
if (area.classList.contains('timeline-editor')) {
this.journalInstances.set(id, editorInstance);
editorInstance.readOnly(true);
}
if (id === 'resolution-notes') {
editorInstance.toolbar.show();
}
return editorInstance;
}
setupListeners(area) {
const editor = this.sunEditorInstances.get(area.id);
if (!area.classList.contains('timeline-editor')) {
editor.onInput = () => {
// only show form buttons for non-timeline suneditors because they have flask-wtf fields associated
showFormButtons();
}
} else {
editor.onBlur = async () => {
if (editor.core.isReadOnly) {
return;
}
if (area.classList.contains('timeline-editor')) {
const response = await showSwal(
'Changes not saved',
'Save updates to the work note?',
'question'
);
if (response.isConfirmed) {
await this.saveNote(area.id)
editor.readOnly(true);
editor.toolbar.hide();
} else {
editor.core.focus();
}
}
}
editor.onChange = () => {
this.changeContent = true;
}
}
}
async setUpMultipleSunEditors(editorClass) {
const textAreas = document.querySelectorAll(editorClass);
let textAreaIds = await Array.from(textAreas).map(function (editor) {
return editor.id;
});
textAreaIds.forEach((id, index) => {
this.createSunEditor(id);
});
}
checkSunEditorForContent(editorInstanceId) {
const editorContents = editorInstanceId.getContents()
const editorImages = editorInstanceId.getImagesInfo()
const isEmpty = editorContents.replace(/<[^>]+>/g, '').trim();
// return true if empty
return isEmpty !== '' || editorImages.length !== 0;
}
clearSunEditorContent(id) {
this.sunEditorInstances.get(id)?.setContents('');
}
// Method to disable all sunEditor instances
disableAllEditors() {
this.sunEditorInstances.forEach((editor, key) => {
if (this.journalInstances.has(key)) {
return;
}
editor.readOnly(true);
});
}
// Method to enable all sunEditor instances
enableAllEditors() {
this.sunEditorInstances.forEach(editor => {
editor.readOnly(false);
editor.toolbar.show();
editor.setOptions({
resizingBar: true,
});
this.sunEditorInstances.forEach(editor => {
editor.setOptions(this.normalOptions);
});
});
}
// Method to enable one sunEditor instances
enableSingleEditor(editorId) {
const editorInstance = this.sunEditorInstances.get(editorId);
if (editorInstance) {
editorInstance.readOnly(false);
editorInstance.toolbar.show();
editorInstance.core.focus();
}
}
disableSingleEditor(editorId) {
const editorInstance = this.sunEditorInstances.get(editorId);
if (editorInstance) {
editorInstance.readOnly(true);
editorInstance.toolbar.show();
editorInstance.core.blur();
}
}
destroySunEditor(id) {
if (this.sunEditorInstances.has(id)) {
const editor = this.sunEditorInstances.get(id);
editor.destroy();
this.sunEditorInstances.delete(id);
this.journalInstances.delete(id);
}
}
async saveNote(editorId) {
const editor = this.sunEditorInstances.get(editorId);
const updatedContent = editor.getContents();
editor.toolbar.hide();
editor.readOnly(true);
const apiArgs = {
'record_id': editor.core.context.element.originElement.dataset.recordId,
'ticket_number': this.ticketNumber.value,
'ticket_type': this.ticketType.value,
'updated_note': updatedContent
};
try {
const response = await fetch('/api/update-worknote/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(apiArgs),
});
const result = await response.json();
if (!response.ok) {
await showSwal('Error', result['error'], 'error');
}
} catch (error) {
console.error('Failed to update note:', error);
}
}
}
export const sunEditorClass = new SunEditorClass();