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 = ` -
- -
- Option -
-
`; +
+
+ +
+ Option +
+
+
`; 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], - }); -}