import {Controller} from '@hotwired/stimulus' type TOutletEventLookup = boolean | {[k: string]: TOutletEventLookup} export type TOutletChangeData = | { eventKey?: string data?: T } | undefined export default class OutletManagerController extends Controller { static values = { outletEvents: Array, } declare readonly outletEventsValue: Array declare readonly hasOutletEventsValue: boolean static outlets = ['toggleable', 'options', 'string-match'] declare readonly toggleableOutlets: Array> declare readonly hasToggleableOutlet: boolean declare readonly optionsOutlets: Array> declare readonly hasOptionsOutlet: boolean declare readonly stringMatchOutlets: Array> declare readonly hasStringMatchOutlet: boolean outletEventsLookup: TOutletEventLookup | null = null static domEvents: {[k: string]: boolean} = { abort: true, afterprint: true, animationend: true, animationiteration: true, animationstart: true, beforeprint: true, beforeunload: true, blur: true, canplay: true, canplaythrough: true, change: true, click: true, contextmenu: true, copy: true, cut: true, dblclick: true, drag: true, dragend: true, dragenter: true, dragleave: true, dragover: true, dragstart: true, drop: true, durationchange: true, ended: true, error: true, focus: true, focusin: true, focusout: true, fullscreenchange: true, fullscreenerror: true, hashchange: true, input: true, invalid: true, keydown: true, keypress: true, keyup: true, load: true, loadeddata: true, loadedmetadata: true, loadstart: true, message: true, mousedown: true, mouseenter: true, mouseleave: true, mousemove: true, mouseover: true, mouseout: true, mouseup: true, mousewheel: true, offline: true, online: true, open: true, pagehide: true, pageshow: true, paste: true, pause: true, play: true, playing: true, popstate: true, progress: true, ratechange: true, resize: true, reset: true, scroll: true, search: true, seeked: true, seeking: true, select: true, show: true, stalled: true, storage: true, submit: true, suspend: true, timeupdate: true, toggle: true, touchcancel: true, touchend: true, touchmove: true, touchstart: true, transitionend: true, unload: true, volumechange: true, waiting: true, wheel: true, } eventRecords: Map = new Map() getOutlets(): Array> | null | void { return null } outletUpdate(event: Event, data: TOutletChangeData): void {} // eslint-disable-line no-unused-vars getState(): T { return null as T } connect() { this.syncOutlets() } syncOutlets() { const event = new Event('init') this.sendToOutlets(event, { data: this.getState(), eventKey: this.getEventKey(event), }) } sendToOutlets(event: Event, updateTo: TOutletChangeData = {}): void { const eventKey = updateTo.eventKey ?? this.getEventKey(event) const outlets = this.#outlets if (outlets?.length) { for (const index in outlets) { const outlet = outlets[index] if (outlet.isListeningForOutletEvent(eventKey) && !this.hasHeardEvent(event)) { const isSameControllerType = this.identifier === outlet.identifier outlet.outletUpdate(event, {eventKey, data: isSameControllerType ? updateTo.data : undefined}) } } } } isListeningForOutletEvent(eventTypes: string) { const eventTypeNames = eventTypes.split('-') if (!eventTypeNames.length) { return false } let lookup = this.outletEvents for (let i = 0; i < eventTypeNames.length; i++) { const name = eventTypeNames[i] if (typeof lookup === 'boolean') { return lookup } const hasWildCard = lookup['*'] !== undefined let nextKey = name if (hasWildCard) { nextKey = '*' } else { const isListeningForDOMEvent = lookup.domEvent !== undefined if (isListeningForDOMEvent && !this.isDOMEventName(name)) { return false } else if (isListeningForDOMEvent) { nextKey = 'domEvent' } } if (!lookup[nextKey]) { return false } lookup = lookup[nextKey] } return true } isDOMEventName(eventName: string) { return OutletManagerController.domEvents[eventName] } getEventKey(event: Event) { const pre = this.event_key_prefix const post = this.event_key_postfix const maybePreHyphen = pre ? '-' : '' const maybePrefix = pre ?? '' const maybePostHyphen = post ? '-' : '' const maybePostfix = post ?? '' return `${this.identifier}-${maybePrefix}${maybePreHyphen}${event.type}${maybePostHyphen}${maybePostfix}` } hasHeardEvent(event: Event) { if (this.eventRecords.has(event)) { return true } this.eventRecords.set(event, true) setTimeout(() => this.eventRecords.delete(event)) return false } get event_key_prefix() { return '' } get event_key_postfix() { return '' } get outletEvents() { if (!this.outletEventsLookup && this.hasOutletEventsValue) { this.outletEventsLookup = this.outletEventsValue.reduce((acc, eventType) => { let step = acc eventType.split('-').forEach((eventTypeName, i, splitArr) => { if (typeof step === 'boolean') { return } if (i === splitArr.length - 1) { step[eventTypeName] = true } else if (step[eventTypeName] === undefined) { step[eventTypeName] = {} } step = step[eventTypeName] }) return acc }, {} as TOutletEventLookup) } else if (!this.outletEventsLookup) { this.outletEventsLookup = {'*': true} } return this.outletEventsLookup } get #outlets(): Array> | null | void { const outlets = this.getOutlets() if (outlets) { return outlets } const defaultOutlets: Array> = [] if (this.hasToggleableOutlet) { defaultOutlets.push(...this.toggleableOutlets) } if (this.hasOptionsOutlet) { defaultOutlets.push(...this.optionsOutlets) } if (this.hasStringMatchOutlet) { defaultOutlets.push(...this.stringMatchOutlets) } return defaultOutlets } }