From aa3863f17c7ddc284e420bb09528bb2cb8564bc1 Mon Sep 17 00:00:00 2001 From: LB Johnston Date: Wed, 28 Jun 2023 20:28:03 +1000 Subject: [PATCH] DropdownController - fix test & refine events & methods - Test that uses setTimeout never ran as Tippy relies on `transitionend` events, which JSDom will not dispatch. Rework tests to correctly test this custom event gets dispatched - Use Stimulus dispatch method for custom event dispatching as the controller name (e.g. 'w-dropdown') will automatically be added, work around TypeScript bug in Stimulus which should be fixed in a future release https://github.com/hotwired/stimulus/issues/642 - Add show/hide methods, pull out options to own get method - Add unit tests for show/hide, content being in dropdown - Relates to #10557 --- .../controllers/DropdownController.test.js | 71 +++++++++++++++---- client/src/controllers/DropdownController.ts | 41 ++++++++--- 2 files changed, 91 insertions(+), 21 deletions(-) diff --git a/client/src/controllers/DropdownController.test.js b/client/src/controllers/DropdownController.test.js index 59381ee9b4..919152c316 100644 --- a/client/src/controllers/DropdownController.test.js +++ b/client/src/controllers/DropdownController.test.js @@ -4,11 +4,9 @@ import { DropdownController } from './DropdownController'; describe('DropdownController', () => { let application; - beforeAll(() => { - application?.stop(); - + beforeEach(async () => { document.body.innerHTML = ` -
+
Option @@ -17,25 +15,74 @@ describe('DropdownController', () => { application = Application.start(); application.register('w-dropdown', DropdownController); + + await Promise.resolve(requestAnimationFrame); + + // set all animation durations to 0 so that tests can ignore animation delays + // Tippy relies on transitionend which is not yet supported in JSDom + // https://github.com/jsdom/jsdom/issues/1781 + + document + .querySelectorAll('[data-controller="w-dropdown"]') + .forEach((element) => { + application + .getControllerForElementAndIdentifier(element, 'w-dropdown') + .tippy.setProps({ duration: 0 }); // tippy will merge props with whatever has already been set + }); }); - it('initialises Tippy.js on connect', () => { + afterEach(() => { + jest.restoreAllMocks(); + application?.stop(); + }); + + it('initialises Tippy.js on connect and shows content in a dropdown', () => { const toggle = document.querySelector('[data-w-dropdown-target="toggle"]'); const content = document.querySelector( '[data-w-dropdown-target="content"]', ); expect(toggle.getAttribute('aria-expanded')).toBe('false'); expect(content).toBe(null); + + toggle.dispatchEvent(new Event('click')); + + const expandedContent = document.querySelectorAll('[role="tooltip"]'); + expect(expandedContent).toHaveLength(1); + + expect(expandedContent[0].innerHTML).toContain('Option'); }); - it('triggers custom event on activation', () => { + it('triggers custom event on activation', async () => { const toggle = document.querySelector('[data-w-dropdown-target="toggle"]'); - const mock = jest.fn(); - document.addEventListener('w-dropdown:shown', mock); + + const mock = new Promise((resolve) => { + document.addEventListener('w-dropdown:shown', (event) => { + resolve(event); + }); + }); + toggle.dispatchEvent(new Event('click')); - // Leave time for animation to complete. - setTimeout(() => { - expect(mock).toHaveBeenCalled(); - }, 500); + + const event = await mock; + + expect(event).toEqual( + expect.objectContaining({ type: 'w-dropdown:shown', target: document }), + ); + }); + + it('should support methods to show and hide the dropdown', async () => { + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0); + + const dropdownElement = document.querySelector( + '[data-controller="w-dropdown"]', + ); + + dropdownElement.dispatchEvent(new CustomEvent('custom:show')); + + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(1); + + dropdownElement.dispatchEvent(new CustomEvent('custom:hide')); + + expect(document.querySelectorAll('[role="tooltip"]')).toHaveLength(0); }); }); diff --git a/client/src/controllers/DropdownController.ts b/client/src/controllers/DropdownController.ts index b321c92cd8..379e1281e0 100644 --- a/client/src/controllers/DropdownController.ts +++ b/client/src/controllers/DropdownController.ts @@ -25,13 +25,32 @@ export class DropdownController extends Controller { declare readonly toggleTarget: HTMLButtonElement; declare readonly contentTarget: HTMLDivElement; declare readonly hideOnClickValue: boolean; + declare readonly hasContentTarget: boolean; + tippy?: Instance; connect() { + this.tippy = tippy(this.toggleTarget, this.options); + } + + hide() { + this.tippy?.hide(); + } + + show() { + this.tippy?.show(); + } + + /** + * Default Tippy Options + */ + get options(): Partial { // If the dropdown toggle uses an ARIA label, use this as a hover tooltip. const hoverTooltip = this.toggleTarget.getAttribute('aria-label'); let hoverTooltipInstance: Instance; - this.contentTarget.hidden = false; + if (this.hasContentTarget) { + this.contentTarget.hidden = false; + } if (hoverTooltip) { hoverTooltipInstance = tippy(this.toggleTarget, { @@ -51,11 +70,17 @@ export class DropdownController extends Controller { plugins.push(hideTooltipOnClickInside); } - /** - * Default Tippy Options - */ - const tippyOptions: Partial = { - content: this.contentTarget as Content, + const onShown = () => { + this.dispatch('shown', { + // work around for target type bug https://github.com/hotwired/stimulus/issues/642 + target: ((key = 'document') => window[key])(), + }); + }; + + return { + ...(this.hasContentTarget + ? { content: this.contentTarget as Content } + : {}), trigger: 'click', interactive: true, theme: 'dropdown', @@ -67,7 +92,7 @@ export class DropdownController extends Controller { } }, onShown() { - document.dispatchEvent(new CustomEvent('w-dropdown:shown')); + onShown(); }, onHide() { if (hoverTooltipInstance) { @@ -75,7 +100,5 @@ export class DropdownController extends Controller { } }, }; - - tippy(this.toggleTarget, tippyOptions); } }