modal-workflow - block additional opens & add trigger focus management (#7568). Fix #4006

Co-authored-by: Thibaud Colas <thibaudcolas@gmail.com>
pull/7833/head
LB (Ben Johnston) 2022-01-15 10:47:23 +10:00 zatwierdzone przez GitHub
rodzic 317f100a78
commit a7aabf76ac
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
5 zmienionych plików z 212 dodań i 0 usunięć

Wyświetl plik

@ -33,6 +33,7 @@ Changelog
* Fix: Additional login form fields from `WAGTAILADMIN_USER_LOGIN_FORM` are now rendered correctly (Michael Karamuth)
* Fix: Icon only button styling issue on small devices where height would not be set correctly (Vu Pham)
* Fix: Add padding to the Draftail editor to ensure `ol` items are not cut off (Khanh Hoang)
* Fix: Prevent opening choosers multiple times for Image, Page, Document, Snippet (LB (Ben Johnston))
2.15.2 (xx.xx.xxxx) - IN DEVELOPMENT

Wyświetl plik

@ -0,0 +1,62 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`modal-workflow should generate a modal when called that is removed when closed 1`] = `
HTMLCollection [
<div
aria-hidden="false"
class="modal fade in"
role="dialog"
tabindex="-1"
>
<div
class="modal-dialog"
>
<div
class="modal-content"
>
<button
class="button close button--icon text-replace"
data-dismiss="modal"
type="button"
>
<svg
aria-hidden="true"
class="icon icon-cross"
focusable="false"
>
<use
href="#icon-cross"
/>
</svg>
Close
</button>
<div
class="modal-body"
>
<div
id="url"
>
path/to/endpoint
</div>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>,
]
`;

Wyświetl plik

@ -25,6 +25,10 @@ function ModalWorkflow(opts) {
/* remove any previous modals before continuing (closing doesn't remove them from the dom) */
$('body > .modal').remove();
// disable the trigger element so it cannot be clicked twice while modal is loading
self.triggerElement = document.activeElement;
self.triggerElement.setAttribute('disabled', true);
// set default contents of container
const iconClose = '<svg class="icon icon-cross" aria-hidden="true" focusable="false"><use href="#icon-cross"></use></svg>';
const container = $('<div class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">\n <div class="modal-dialog">\n <div class="modal-content">\n <button type="button" class="button close button--icon text-replace" data-dismiss="modal">' + iconClose + wagtailConfig.STRINGS.CLOSE + '</button>\n <div class="modal-body"></div>\n </div><!-- /.modal-content -->\n </div><!-- /.modal-dialog -->\n</div>');
@ -33,6 +37,17 @@ function ModalWorkflow(opts) {
$('body').append(container);
container.modal('hide');
// add listener - once modal is about to be hidden, re-enable the trigger
container.on('hide.bs.modal', () => {
self.triggerElement.removeAttribute('disabled');
});
// add listener - once modal is fully hidden (closed & css transitions end) - re-focus on trigger and remove from DOM
container.on('hidden.bs.modal', function () {
self.triggerElement.focus();
container.remove();
});
self.body = container.find('.modal-body');
self.loadUrl = function (url, urlParams) {

Wyświetl plik

@ -0,0 +1,133 @@
import $ from 'jquery';
window.$ = $;
window.jQuery = $;
import '../../../../wagtail/admin/static_src/wagtailadmin/js/vendor/bootstrap-modal';
import './modal-workflow';
$.get = jest.fn().mockImplementation((url, data, cb) => {
cb(JSON.stringify({ html: `<div id="url">${url}</div>`, data, step: 'start' }));
return { fail: jest.fn() };
});
describe('modal-workflow', () => {
beforeEach(() => {
document.body.innerHTML =
'<div id="content"><button class="button action-choose" id="trigger">Open</button></div>';
});
it('exposes module as global', () => {
expect(window.ModalWorkflow).toBeDefined();
});
it('should generate a modal when called that is removed when closed', () => {
let modalWorkflow;
const openModal = () => {
// eslint-disable-next-line new-cap
modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' });
};
expect(document.getElementsByClassName('modal')).toHaveLength(0);
const triggerButton = document.getElementById('trigger');
triggerButton.addEventListener('click', openModal);
triggerButton.dispatchEvent(new MouseEvent('click'));
// check that modal has been created in the DOM
const modal = document.getElementsByClassName('modal');
expect(modal).toHaveLength(1);
expect(modal).toMatchSnapshot();
// check that modalWorkflow returns self
expect(modalWorkflow).toBeInstanceOf(Object);
expect(modalWorkflow).toHaveProperty('close', expect.any(Function));
// close modal & check it is removed from the DOM
modalWorkflow.close();
expect(document.getElementsByClassName('modal')).toHaveLength(0);
});
it('should pull focus from the trigger element to the modal and back to the trigger when closed', () => {
let modalWorkflow;
const openModal = () => {
// eslint-disable-next-line new-cap
modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' });
};
expect(document.getElementsByClassName('modal')).toHaveLength(0);
const triggerButton = document.getElementById('trigger');
triggerButton.focus();
triggerButton.addEventListener('click', openModal);
expect(document.activeElement).toEqual(triggerButton);
triggerButton.dispatchEvent(new MouseEvent('click'));
// check that modal has been created in the DOM & takes focus
expect(document.getElementsByClassName('modal')).toHaveLength(1);
expect(document.activeElement.className).toEqual('modal fade in');
// close modal & check that focus moves back to the trigger
modalWorkflow.close();
expect(document.getElementsByClassName('modal')).toHaveLength(0);
expect(document.activeElement).toEqual(triggerButton);
});
it('should disable the trigger element when the modal is opened and re-enable it when closing', () => {
let modalWorkflow;
const openModal = () => {
// eslint-disable-next-line new-cap
modalWorkflow = window.ModalWorkflow({ url: 'path/to/endpoint' });
};
expect(document.getElementsByClassName('modal')).toHaveLength(0);
const triggerButton = document.getElementById('trigger');
triggerButton.focus();
triggerButton.addEventListener('click', openModal);
expect(triggerButton.disabled).toBe(false);
triggerButton.dispatchEvent(new MouseEvent('click'));
// check that the trigger button is disabled
expect(triggerButton.disabled).toBe(true);
// close modal & check that trigger button is re-enabled
modalWorkflow.close();
expect(triggerButton.disabled).toBe(false);
});
it('should handle onload and URL param options', () => {
const onload = { start: jest.fn() };
const urlParams = { page: 23 };
let modalWorkflow;
const openModal = () => {
// eslint-disable-next-line new-cap
modalWorkflow = window.ModalWorkflow({
url: 'path/to/endpoint',
urlParams,
onload,
});
};
expect(onload.start).not.toHaveBeenCalled();
const triggerButton = document.getElementById('trigger');
triggerButton.addEventListener('click', openModal);
const event = new MouseEvent('click');
triggerButton.dispatchEvent(event);
expect(modalWorkflow).toBeInstanceOf(Object);
// important: see mock implementation above, returning a response with injected data to validate behaviour
const response = { data: urlParams, html: '<div id="url">path/to/endpoint</div>', step: 'start' };
expect(onload.start).toHaveBeenCalledWith(modalWorkflow, response);
});
});

Wyświetl plik

@ -41,6 +41,7 @@
* Additional login form fields from `WAGTAILADMIN_USER_LOGIN_FORM` are now rendered correctly (Michael Karamuth)
* Fix icon only button styling issue on small devices where height would not be set correctly (Vu Pham)
* Add padding to the Draftail editor to ensure `ol` items are not cut off (Khanh Hoang)
* Prevent opening choosers multiple times for Image, Page, Document, Snippet (LB (Ben Johnston))
## Upgrade considerations