diff --git a/client/src/components/StreamField/blocks/StreamBlock.js b/client/src/components/StreamField/blocks/StreamBlock.js
index ff6c5bfff6..d8b09a76fc 100644
--- a/client/src/components/StreamField/blocks/StreamBlock.js
+++ b/client/src/components/StreamField/blocks/StreamBlock.js
@@ -17,7 +17,7 @@ import ComboBox, {
comboBoxNoResults,
comboBoxTriggerLabel,
} from '../../ComboBox/ComboBox';
-import { hideTooltipOnEsc } from '../../../includes/initTooltips';
+import { hideTooltipOnEsc } from '../../../controllers/TooltipController';
import {
addErrorMessages,
removeErrorMessages,
diff --git a/client/src/controllers/DropdownController.test.js b/client/src/controllers/DropdownController.test.js
index 37e5c348b7..22a9088f13 100644
--- a/client/src/controllers/DropdownController.test.js
+++ b/client/src/controllers/DropdownController.test.js
@@ -1,17 +1,21 @@
import { Application } from '@hotwired/stimulus';
import { DropdownController } from './DropdownController';
+jest.useFakeTimers();
+
describe('DropdownController', () => {
let application;
beforeEach(async () => {
document.body.innerHTML = `
-
`;
+ `;
application = Application.start();
application.register('w-dropdown', DropdownController);
@@ -33,6 +37,7 @@ describe('DropdownController', () => {
afterEach(() => {
jest.restoreAllMocks();
+ document.body.innerHTML = '';
application?.stop();
});
@@ -76,6 +81,28 @@ describe('DropdownController', () => {
);
});
+ it("should ensure the tooltip closes on 'esc' keydown", async () => {
+ const toggle = document.getElementById('toggle');
+ expect(toggle.getAttribute('aria-expanded')).toBe('false');
+ toggle.dispatchEvent(new Event('click'));
+
+ await jest.runAllTimersAsync();
+
+ // check the tooltip is open
+ expect(toggle.getAttribute('aria-expanded')).toBe('true');
+
+ // now press the escape key
+ document
+ .querySelector('section')
+ .dispatchEvent(
+ new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
+ );
+
+ await Promise.resolve();
+
+ expect(toggle.getAttribute('aria-expanded')).toBe('false');
+ });
+
it('should support methods to show and hide the dropdown', async () => {
expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0);
diff --git a/client/src/controllers/DropdownController.ts b/client/src/controllers/DropdownController.ts
index 1292f4c11e..b7268efba9 100644
--- a/client/src/controllers/DropdownController.ts
+++ b/client/src/controllers/DropdownController.ts
@@ -1,11 +1,82 @@
import { Controller } from '@hotwired/stimulus';
import tippy, { Content, Props, Instance } from 'tippy.js';
-import {
- hideTooltipOnBreadcrumbExpandAndCollapse,
- hideTooltipOnClickInside,
- hideTooltipOnEsc,
- rotateToggleIcon,
-} from '../includes/initTooltips';
+import { hideTooltipOnEsc } from './TooltipController';
+
+/**
+ * Prevents the tooltip from staying open when the breadcrumbs
+ * expand and push the toggle button in the layout.
+ */
+const hideTooltipOnBreadcrumbsChange = {
+ name: 'hideTooltipOnBreadcrumbAndCollapse',
+ fn({ hide }: Instance) {
+ function onBreadcrumbExpandAndCollapse() {
+ hide();
+ }
+
+ return {
+ onShow() {
+ document.addEventListener(
+ 'w-breadcrumbs:opened',
+ onBreadcrumbExpandAndCollapse,
+ );
+ document.addEventListener(
+ 'w-breadcrumbs:closed',
+ onBreadcrumbExpandAndCollapse,
+ );
+ },
+ onHide() {
+ document.removeEventListener(
+ 'w-breadcrumbs:closed',
+ onBreadcrumbExpandAndCollapse,
+ );
+ document.removeEventListener(
+ 'w-breadcrumbs:opened',
+ onBreadcrumbExpandAndCollapse,
+ );
+ },
+ };
+ },
+};
+
+/**
+ * Hides tooltip when clicking inside.
+ */
+const hideTooltipOnClickInside = {
+ name: 'hideTooltipOnClickInside',
+ defaultValue: true,
+ fn(instance: Instance) {
+ const onClick = () => instance.hide();
+
+ return {
+ onShow() {
+ instance.popper.addEventListener('click', onClick);
+ },
+ onHide() {
+ instance.popper.removeEventListener('click', onClick);
+ },
+ };
+ },
+};
+
+/**
+ * If the toggle button has a toggle arrow,
+ * rotate it when open and closed.
+ */
+const rotateToggleIcon = {
+ name: 'rotateToggleIcon',
+ fn(instance: Instance) {
+ const dropdownIcon = instance.reference.querySelector('.icon-arrow-down');
+
+ if (!dropdownIcon) {
+ return {};
+ }
+
+ return {
+ onShow: () => dropdownIcon.classList.add('w-rotate-180'),
+ onHide: () => dropdownIcon.classList.remove('w-rotate-180'),
+ };
+ },
+};
/**
* A Tippy.js tooltip with interactive "dropdown" options.
@@ -65,16 +136,6 @@ export class DropdownController extends Controller {
});
}
- const plugins = [
- hideTooltipOnEsc,
- hideTooltipOnBreadcrumbExpandAndCollapse,
- rotateToggleIcon,
- ];
-
- if (this.hideOnClickValue) {
- plugins.push(hideTooltipOnClickInside);
- }
-
const onShown = () => {
this.dispatch('shown');
};
@@ -88,7 +149,7 @@ export class DropdownController extends Controller {
theme: 'dropdown',
...(this.hasOffsetValue && { offset: this.offsetValue }),
placement: 'bottom',
- plugins,
+ plugins: this.plugins,
onShow() {
if (hoverTooltipInstance) {
hoverTooltipInstance.disable();
@@ -104,4 +165,12 @@ export class DropdownController extends Controller {
},
};
}
+
+ get plugins() {
+ return [
+ hideTooltipOnBreadcrumbsChange,
+ hideTooltipOnEsc,
+ rotateToggleIcon,
+ ].concat(this.hideOnClickValue ? [hideTooltipOnClickInside] : []);
+ }
}
diff --git a/client/src/controllers/TooltipController.test.js b/client/src/controllers/TooltipController.test.js
index 61a0a4d977..bed2c19d7d 100644
--- a/client/src/controllers/TooltipController.test.js
+++ b/client/src/controllers/TooltipController.test.js
@@ -67,6 +67,29 @@ describe('TooltipController', () => {
expect(tooltip.dataset.placement).toEqual('bottom'); // the default placement
});
+ it("should ensure the tooltip closes on 'esc' keydown", async () => {
+ const tooltipTrigger = document.getElementById('tooltip-default');
+
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0);
+
+ tooltipTrigger.dispatchEvent(new Event('mouseenter'));
+
+ await Promise.resolve();
+
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1);
+
+ // now press the escape key
+ document
+ .querySelector('section')
+ .dispatchEvent(
+ new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
+ );
+
+ await Promise.resolve();
+
+ expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0);
+ });
+
it('should create a tooltip that accepts a different placement value', async () => {
const tooltipTrigger = document.getElementById('tooltip-custom');
diff --git a/client/src/controllers/TooltipController.ts b/client/src/controllers/TooltipController.ts
index 3f2961031e..e38d1f1574 100644
--- a/client/src/controllers/TooltipController.ts
+++ b/client/src/controllers/TooltipController.ts
@@ -1,6 +1,30 @@
import { Controller } from '@hotwired/stimulus';
import tippy, { Placement, Props, Instance } from 'tippy.js';
-import { hideTooltipOnEsc } from '../includes/initTooltips';
+import { domReady } from '../utils/domReady';
+
+/**
+ * Hides tooltip when escape key is pressed.
+ */
+export const hideTooltipOnEsc = {
+ name: 'hideOnEsc',
+ defaultValue: true,
+ fn({ hide }: Instance) {
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ hide();
+ }
+ }
+
+ return {
+ onShow() {
+ document.addEventListener('keydown', onKeyDown);
+ },
+ onHide() {
+ document.removeEventListener('keydown', onKeyDown);
+ },
+ };
+ },
+};
/**
* A Tippy.js tooltip with simple popover content.
@@ -51,12 +75,29 @@ export class TooltipController extends Controller {
return {
content: this.contentValue,
placement: this.placementValue,
- plugins: [hideTooltipOnEsc],
+ plugins: this.plugins,
...(this.hasOffsetValue && { offset: this.offsetValue }),
};
}
+ get plugins() {
+ return [hideTooltipOnEsc];
+ }
+
disconnect() {
this.tippy?.destroy();
}
+
+ /**
+ * Ensure we have backwards compatibility for any data-tippy usage on initial load.
+ *
+ * @deprecated RemovedInWagtail70
+ */
+ static afterLoad() {
+ domReady().then(() => {
+ tippy('[data-tippy-content]', {
+ plugins: [hideTooltipOnEsc],
+ });
+ });
+ }
}
diff --git a/client/src/entrypoints/admin/page-chooser-modal.js b/client/src/entrypoints/admin/page-chooser-modal.js
index c83b3b52be..381bcc3074 100644
--- a/client/src/entrypoints/admin/page-chooser-modal.js
+++ b/client/src/entrypoints/admin/page-chooser-modal.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import { ChooserModal } from '../../includes/chooserModal';
-import { initTooltips } from '../../includes/initTooltips';
const PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
browse(modal, jsonData) {
@@ -140,7 +139,6 @@ const PAGE_CHOOSER_MODAL_ONLOAD_HANDLERS = {
});
}
ajaxifyBrowseResults();
- initTooltips();
/*
Focus on the search box when opening the modal.
diff --git a/client/src/entrypoints/admin/wagtailadmin.js b/client/src/entrypoints/admin/wagtailadmin.js
index c223d62353..1576c5e010 100644
--- a/client/src/entrypoints/admin/wagtailadmin.js
+++ b/client/src/entrypoints/admin/wagtailadmin.js
@@ -1,4 +1,3 @@
-import { initTooltips } from '../../includes/initTooltips';
import { initTabs } from '../../includes/tabs';
import initSidePanel from '../../includes/sidePanel';
import {
@@ -11,7 +10,6 @@ import { initMinimap } from '../../components/Minimap';
* Add in here code to run once the page is loaded.
*/
document.addEventListener('DOMContentLoaded', () => {
- initTooltips();
initTabs();
initSidePanel();
initCollapsiblePanels();
@@ -25,11 +23,3 @@ window.addEventListener('load', () => {
initAnchoredPanels();
initMinimap();
});
-
-/**
- * When search results are successful, reinitialise widgets
- * that could be inside the newly injected DOM.
- */
-window.addEventListener('w-swap:success', () => {
- initTooltips(); // reinitialise any tooltips
-});
diff --git a/client/src/includes/chooserModal.js b/client/src/includes/chooserModal.js
index 8a715ea7dc..e05a339d0d 100644
--- a/client/src/includes/chooserModal.js
+++ b/client/src/includes/chooserModal.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import { initTabs } from './tabs';
-import { initTooltips } from './initTooltips';
import { gettext } from '../utils/gettext';
const validateCreationForm = (form) => {
@@ -231,9 +230,6 @@ class ChooserModalOnloadHandlerFactory {
// Reinitialize tabs to hook up tab event listeners in the modal
if (this.modalHasTabs(modal)) initTabs();
- // Reinitialise any tooltips
- initTooltips();
-
this.updateMultipleChoiceSubmitEnabledState(modal);
$('[data-multiple-choice-select]', containerElement).on('change', () => {
this.updateMultipleChoiceSubmitEnabledState(modal);
diff --git a/client/src/includes/initTooltips.test.js b/client/src/includes/initTooltips.test.js
deleted file mode 100644
index 2791902e52..0000000000
--- a/client/src/includes/initTooltips.test.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as tippy from 'tippy.js';
-import { initTooltips } from './initTooltips';
-
-jest.spyOn(tippy, 'default');
-
-beforeEach(() => jest.clearAllMocks());
-
-describe('initTooltips', () => {
- it('should call the Tippy util with the [data-tippy-content] attribute', () => {
- expect(tippy.default).not.toHaveBeenCalled();
- initTooltips();
- expect(tippy.default).toHaveBeenCalledWith('[data-tippy-content]', {
- plugins: [expect.objectContaining({ name: 'hideOnEsc' })],
- });
- });
-});
diff --git a/client/src/includes/initTooltips.ts b/client/src/includes/initTooltips.ts
deleted file mode 100644
index 881affe419..0000000000
--- a/client/src/includes/initTooltips.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import tippy, { Instance } from 'tippy.js';
-
-/**
- * Hides tooltip when escape key is pressed
- */
-export const hideTooltipOnEsc = {
- name: 'hideOnEsc',
- defaultValue: true,
- fn({ hide }: Instance) {
- function onKeyDown(event: KeyboardEvent) {
- if (event.key === 'Escape') {
- hide();
- }
- }
-
- return {
- onShow() {
- document.addEventListener('keydown', onKeyDown);
- },
- onHide() {
- document.removeEventListener('keydown', onKeyDown);
- },
- };
- },
-};
-
-/**
- * Hides tooltip when clicking inside.
- */
-export const hideTooltipOnClickInside = {
- name: 'hideTooltipOnClickInside',
- defaultValue: true,
- fn(instance: Instance) {
- const onClick = () => instance.hide();
-
- return {
- onShow() {
- instance.popper.addEventListener('click', onClick);
- },
- onHide() {
- instance.popper.removeEventListener('click', onClick);
- },
- };
- },
-};
-
-/**
- * Prevents the tooltip from staying open when the breadcrumbs expand and push the toggle button in the layout
- */
-export const hideTooltipOnBreadcrumbExpandAndCollapse = {
- name: 'hideTooltipOnBreadcrumbAndCollapse',
- fn({ hide }: Instance) {
- function onBreadcrumbExpandAndCollapse() {
- hide();
- }
-
- return {
- onShow() {
- document.addEventListener(
- 'w-breadcrumbs:opened',
- onBreadcrumbExpandAndCollapse,
- );
- document.addEventListener(
- 'w-breadcrumbs:closed',
- onBreadcrumbExpandAndCollapse,
- );
- },
- onHide() {
- document.removeEventListener(
- 'w-breadcrumbs:closed',
- onBreadcrumbExpandAndCollapse,
- );
- document.removeEventListener(
- 'w-breadcrumbs:opened',
- onBreadcrumbExpandAndCollapse,
- );
- },
- };
- },
-};
-
-/**
- * If the toggle button has a toggle arrow, rotate it when open and closed
- */
-export const rotateToggleIcon = {
- name: 'rotateToggleIcon',
- fn(instance: Instance) {
- const dropdownIcon = instance.reference.querySelector('.icon-arrow-down');
-
- if (!dropdownIcon) {
- return {};
- }
-
- return {
- onShow: () => dropdownIcon.classList.add('w-rotate-180'),
- onHide: () => dropdownIcon.classList.remove('w-rotate-180'),
- };
- },
-};
-
-/**
- * Default Tippy Tooltips
- */
-export function initTooltips() {
- tippy('[data-tippy-content]', {
- plugins: [hideTooltipOnEsc],
- });
-}