diff --git a/client/src/controllers/InitController.test.js b/client/src/controllers/InitController.test.js index 088abb8d72..e48a8feeea 100644 --- a/client/src/controllers/InitController.test.js +++ b/client/src/controllers/InitController.test.js @@ -51,6 +51,7 @@ describe('InitController', () => { expect(handleEvent).toHaveBeenCalledTimes(1); expect(testDiv.getAttribute('data-controller')).toBeNull(); + expect({ ...testDiv.dataset }).toEqual({}); }); }); @@ -147,5 +148,77 @@ describe('InitController', () => { 'other-custom:event', ]); }); + + it('should support the ability to block additional events and classes removal', async () => { + jest.clearAllMocks(); + + expect(handleEvent).not.toHaveBeenCalled(); + + document.addEventListener( + 'w-init:ready', + (event) => { + event.preventDefault(); + }, + { once: true }, + ); + + const article = document.createElement('article'); + article.id = 'article'; + article.innerHTML = `

CONTENT

`; + + document.body.append(article); + + await jest.runAllTimersAsync(); + + // only once - the w-init, no other events should fire + expect(handleEvent).toHaveBeenCalledTimes(1); + expect(handleEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'w-init:ready' }), + ); + }); + }); + + describe('when using detail for the dispatched events', () => { + const handleEvent = jest.fn(); + + document.addEventListener('w-init:ready', handleEvent); + document.addEventListener('my-custom:event', handleEvent); + + const detail = { someMessage: 'some value' }; + + beforeAll(() => { + jest.clearAllMocks(); + + application?.stop(); + + document.body.innerHTML = ` +
+ Test body +
+ `; + + application = Application.start(); + }); + + it('should dispatch event with a detail', async () => { + application.register('w-init', InitController); + await Promise.resolve(); // no delay, just wait for the next tick + + expect(handleEvent).toHaveBeenCalledTimes(2); + + const [[firstEvent], [secondEvent]] = handleEvent.mock.calls; + + expect(firstEvent.type).toEqual('w-init:ready'); + expect(firstEvent.detail).toEqual(detail); + + expect(secondEvent.type).toEqual('my-custom:event'); + expect(secondEvent.detail).toEqual(detail); + }); }); }); diff --git a/client/src/controllers/InitController.ts b/client/src/controllers/InitController.ts index 336de58750..0eee5d295f 100644 --- a/client/src/controllers/InitController.ts +++ b/client/src/controllers/InitController.ts @@ -2,8 +2,8 @@ import { Controller } from '@hotwired/stimulus'; import { debounce } from '../utils/debounce'; /** - * Adds the ability for a controlled element to add or remove classes - * when ready to be interacted with. + * Adds the ability for a controlled element to dispatch an event and also + * add or remove classes when ready to be interacted with. * * @example - Dynamic classes when ready *
@@ -14,20 +14,32 @@ import { debounce } from '../utils/debounce'; *
* When the DOM is ready, two additional custom events will be dispatched; `custom:event` and `other-custom:event`. *
+ * + * @example - Detail dispatching + *
+ * When the DOM is ready, the detail with value of a JSON object above will be dispatched. + *
*/ export class InitController extends Controller { static classes = ['ready', 'remove']; static values = { delay: { default: -1, type: Number }, + detail: { default: {}, type: Object }, event: { default: '', type: String }, }; - declare readonly readyClasses: string[]; - declare readonly removeClasses: string[]; - - declare eventValue: string; + /** The delay before applying ready classes and dispatching events. */ declare delayValue: number; + /** The detail value to be dispatched with events when the element is ready. */ + declare detailValue: Record; + /** The custom events to be dispatched when the element is ready. */ + declare eventValue: string; + + /** The classes to be added when the element is ready. */ + declare readonly readyClasses: string[]; + /** The classes to be removed when the element is ready. */ + declare readonly removeClasses: string[]; connect() { this.ready(); @@ -41,25 +53,56 @@ export class InitController extends Controller { * Support the ability to also dispatch custom event names. */ ready() { - const events = this.eventValue.split(' ').filter(Boolean); const delayValue = this.delayValue; + const detail = { ...this.detailValue }; debounce(() => true, delayValue < 0 ? null : delayValue)().then(() => { this.element.classList.add(...this.readyClasses); this.element.classList.remove(...this.removeClasses); - this.dispatch('ready', { bubbles: true, cancelable: false }); - events.forEach((name) => { - this.dispatch(name, { bubbles: true, cancelable: false, prefix: '' }); - }); + + if ( + this.dispatch('ready', { + bubbles: true, + cancelable: true, + detail, + }).defaultPrevented + ) { + return; + } + + this.eventValue + .split(' ') + .filter(Boolean) + .forEach((name) => { + this.dispatch(name, { + bubbles: true, + cancelable: false, + detail, + prefix: '', + }); + }); + this.remove(); }); } /** * Allow the controller to remove itself as it's no longer needed when the init has completed. + * Removing the controller reference and all other specific value/classes data attributes. */ remove() { const element = this.element; + + (this.constructor as typeof InitController).classes.forEach((key) => { + element.removeAttribute(`data-${this.identifier}-${key}-class`); + }); + + Object.keys((this.constructor as typeof InitController).values).forEach( + (key) => { + element.removeAttribute(`data-${this.identifier}-${key}-value`); + }, + ); + const controllerAttribute = this.application.schema.controllerAttribute; const controllers = element.getAttribute(controllerAttribute)?.split(' ') ?? [];