diff --git a/components/form/form-element-mixin.js b/components/form/form-element-mixin.js index 758ea619849..57400abc8b6 100644 --- a/components/form/form-element-mixin.js +++ b/components/form/form-element-mixin.js @@ -1,5 +1,6 @@ import { isCustomFormElement } from './form-helper.js'; import { LocalizeCoreElement } from '../../lang/localize-core-element.js'; +import { ProviderController } from '../../helpers/subscriptionControllers.js'; export class FormElementValidityState { @@ -111,10 +112,8 @@ export const FormElementMixin = superclass => class extends LocalizeCoreElement( constructor() { super(); - this._validationCustomConnected = this._validationCustomConnected.bind(this); this._onFormElementErrorsChange = this._onFormElementErrorsChange.bind(this); - this._validationCustoms = new Set(); this._validity = new FormElementValidityState({}); /** @ignore */ this.forceInvalid = false; @@ -130,7 +129,10 @@ export const FormElementMixin = superclass => class extends LocalizeCoreElement( this.childErrors = new Map(); this._errors = []; - this.shadowRoot.addEventListener('d2l-validation-custom-connected', this._validationCustomConnected); + this._validationCustomsController = new ProviderController(this, {}, + { eventName: 'd2l-validation-custom-connected' } + ); + this.shadowRoot.addEventListener('d2l-form-element-errors-change', this._onFormElementErrorsChange); } @@ -153,6 +155,16 @@ export const FormElementMixin = superclass => class extends LocalizeCoreElement( return this._validity; } + connectedCallback() { + super.connectedCallback(); + this._validationCustomsController.hostConnected(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._validationCustomsController.hostDisconnected(); + } + updated(changedProperties) { if (changedProperties.has('_errors') || changedProperties.has('childErrors')) { let errors = this._errors; @@ -173,11 +185,15 @@ export const FormElementMixin = superclass => class extends LocalizeCoreElement( } } + getController() { + return this._validationCustomsController; + } + async requestValidate(showNewErrors = true) { if (this.noValidate) { return []; } - const customs = [...this._validationCustoms].filter(custom => custom.forElement === this || !isCustomFormElement(custom.forElement)); + const customs = Array.from(this._validationCustomsController.subscribers.values()).filter(custom => custom.forElement === this || !isCustomFormElement(custom.forElement)); const results = await Promise.all(customs.map(custom => custom.validate())); const errors = customs.map(custom => custom.failureText).filter((_, i) => !results[i]); if (!this.validity.valid) { @@ -208,14 +224,6 @@ export const FormElementMixin = superclass => class extends LocalizeCoreElement( return this._errors; } - validationCustomConnected(custom) { - this._validationCustoms.add(custom); - } - - validationCustomDisconnected(custom) { - this._validationCustoms.delete(custom); - } - _onFormElementErrorsChange(e) { e.stopPropagation(); const errors = e.detail.errors; @@ -230,16 +238,4 @@ export const FormElementMixin = superclass => class extends LocalizeCoreElement( } } - _validationCustomConnected(e) { - e.stopPropagation(); - const custom = e.composedPath()[0]; - this.validationCustomConnected(custom); - - const onDisconnect = () => { - custom.removeEventListener('d2l-validation-custom-disconnected', onDisconnect); - this.validationCustomDisconnected(custom); - }; - custom.addEventListener('d2l-validation-custom-disconnected', onDisconnect); - } - }; diff --git a/components/form/form-mixin.js b/components/form/form-mixin.js index 98afbc8a54d..e075cfc75d5 100644 --- a/components/form/form-mixin.js +++ b/components/form/form-mixin.js @@ -6,6 +6,7 @@ import { getComposedActiveElement } from '../../helpers/focus.js'; import { getUniqueId } from '../../helpers/uniqueId.js'; import { LocalizeCoreElement } from '../../lang/localize-core-element.js'; import { localizeFormElement } from './form-element-localize-helper.js'; +import { ProviderController } from '../../helpers/subscriptionControllers.js'; export const FormMixin = superclass => class extends LocalizeCoreElement(superclass) { @@ -27,21 +28,25 @@ export const FormMixin = superclass => class extends LocalizeCoreElement(supercl this.trackChanges = false; this._tooltips = new Map(); - this._validationCustoms = new Set(); this._errors = new Map(); + this._validationCustomsController = new ProviderController(this, {}, + { eventName: 'd2l-validation-custom-connected' } + ); + this.addEventListener('d2l-form-errors-change', this._onErrorsChange); this.addEventListener('d2l-form-element-errors-change', this._onErrorsChange); - this.addEventListener('d2l-validation-custom-connected', this._validationCustomConnected); } connectedCallback() { super.connectedCallback(); + this._validationCustomsController.hostConnected(); window.addEventListener('beforeunload', this._onUnload); } disconnectedCallback() { super.disconnectedCallback(); + this._validationCustomsController.hostDisconnected(); window.removeEventListener('beforeunload', this._onUnload); } @@ -52,6 +57,10 @@ export const FormMixin = superclass => class extends LocalizeCoreElement(supercl this.addEventListener('focusout', this._onFormElementChange); } + getController() { + return this._validationCustomsController; + } + // eslint-disable-next-line no-unused-vars async requestSubmit(submitter) { throw new Error('FormMixin.requestSubmit must be overridden'); @@ -154,7 +163,7 @@ export const FormMixin = superclass => class extends LocalizeCoreElement(supercl if (isCustomFormElement(ele)) { return ele.validate(showNewErrors); } else if (isNativeFormElement(ele)) { - const customs = [...this._validationCustoms].filter(custom => custom.forElement === ele); + const customs = Array.from(this._validationCustomsController.subscribers.values()).filter(custom => custom.forElement === ele); const results = await Promise.all(customs.map(custom => custom.validate())); const errors = customs.map(custom => custom.failureText).filter((_, i) => !results[i]); if (!ele.checkValidity()) { @@ -171,16 +180,4 @@ export const FormMixin = superclass => class extends LocalizeCoreElement(supercl return []; } - _validationCustomConnected(e) { - e.stopPropagation(); - const custom = e.composedPath()[0]; - this._validationCustoms.add(custom); - - const onDisconnect = () => { - custom.removeEventListener('d2l-validation-custom-disconnected', onDisconnect); - this._validationCustoms.delete(custom); - }; - custom.addEventListener('d2l-validation-custom-disconnected', onDisconnect); - } - }; diff --git a/components/list/list-item-checkbox-mixin.js b/components/list/list-item-checkbox-mixin.js index 58d3c5b2e91..0cf0c0b4205 100644 --- a/components/list/list-item-checkbox-mixin.js +++ b/components/list/list-item-checkbox-mixin.js @@ -181,13 +181,13 @@ export const ListItemCheckboxMixin = superclass => class extends SkeletonMixin(L if (this._selectionProvider === nestedList) return; if (this._selectionProvider && this._selectionProvider !== nestedList) { - this._selectionProvider.unsubscribeObserver(this); + this._selectionProvider.getController('observer').unsubscribe(this); this._selectionProvider = null; } if (nestedList) { this._selectionProvider = nestedList; - this._selectionProvider.subscribeObserver(this); + this._selectionProvider.getController('observer').subscribe(this); } } diff --git a/components/selection/selection-input.js b/components/selection/selection-input.js index 640cf5ef48e..2159b2cf6ba 100644 --- a/components/selection/selection-input.js +++ b/components/selection/selection-input.js @@ -1,11 +1,11 @@ import '../inputs/input-checkbox.js'; import { css, html, LitElement } from 'lit-element/lit-element.js'; import { classMap } from 'lit-html/directives/class-map.js'; +import { EventSubscriberController } from '../../helpers/subscriptionControllers.js'; import { ifDefined } from 'lit-html/directives/if-defined.js'; import { LabelledMixin } from '../../mixins/labelled-mixin.js'; import { radioStyles } from '../inputs/input-radio-styles.js'; import { SkeletonMixin } from '../skeleton/skeleton-mixin.js'; - const keyCodes = { SPACE: 32 }; @@ -40,8 +40,7 @@ class Input extends SkeletonMixin(LabelledMixin(LitElement)) { * @type {string} */ key: { type: String }, - _indeterminate: { type: Boolean }, - _provider: { type: Object } + _indeterminate: { type: Boolean } }; } @@ -61,26 +60,21 @@ class Input extends SkeletonMixin(LabelledMixin(LitElement)) { super(); this.selected = false; this._indeterminate = false; + + this._subscriberController = new EventSubscriberController(this, + { onSubscribe: () => { this.requestUpdate(); } }, + { eventName: 'd2l-selection-input-subscribe', controllerId: 'input' } + ); } connectedCallback() { super.connectedCallback(); - // delay subscription otherwise import/upgrade order can cause selection mixin to miss event - requestAnimationFrame(() => { - const evt = new CustomEvent('d2l-selection-input-subscribe', { - bubbles: true, - composed: true, - detail: {} - }); - this.dispatchEvent(evt); - this._provider = evt.detail.provider; - }); + this._subscriberController.hostConnected(); } disconnectedCallback() { super.disconnectedCallback(); - if (!this._provider) return; - this._provider.unsubscribeSelectable(this); + this._subscriberController.hostDisconnected(); } firstUpdated(changedProperties) { @@ -89,8 +83,8 @@ class Input extends SkeletonMixin(LabelledMixin(LitElement)) { } render() { - if (!this._provider) return; - if (this._provider.selectionSingle) { + if (!this._subscriberController.provider) return; + if (this._subscriberController.provider.selectionSingle) { const radioClasses = { 'd2l-input-radio': true, 'd2l-selection-input-radio': true, diff --git a/components/selection/selection-mixin.js b/components/selection/selection-mixin.js index ccc4c4e04ce..cecb4d8c72f 100644 --- a/components/selection/selection-mixin.js +++ b/components/selection/selection-mixin.js @@ -1,3 +1,4 @@ +import { ProviderController } from '../../helpers/subscriptionControllers.js'; import { RtlMixin } from '../../mixins/rtl-mixin.js'; const keyCodes = { @@ -49,17 +50,27 @@ export const SelectionMixin = superclass => class extends RtlMixin(superclass) { constructor() { super(); this.selectionSingle = false; - this._selectionObservers = new Map(); - this._selectionSelectables = new Map(); + + this._observerController = new ProviderController(this, + { updateSubscribers: this._updateObservers.bind(this) }, + { eventName: 'd2l-selection-observer-subscribe' } + ); + + this._selectablesController = new ProviderController(this, + { onSubscribe: this._subscribeSelectable.bind(this), onUnsubscribe: this._unsubscribeSelectable.bind(this) }, + { eventName: 'd2l-selection-input-subscribe' } + ); + } connectedCallback() { super.connectedCallback(); + if (this._observerController) this._observerController.hostConnected(); + if (this._selectablesController) this._selectablesController.hostConnected(); + if (this.selectionSingle) this.addEventListener('keydown', this._handleRadioKeyDown); if (this.selectionSingle) this.addEventListener('keyup', this._handleRadioKeyUp); this.addEventListener('d2l-selection-change', this._handleSelectionChange); - this.addEventListener('d2l-selection-observer-subscribe', this._handleSelectionObserverSubscribe); - this.addEventListener('d2l-selection-input-subscribe', this._handleSelectionInputSubscribe); requestAnimationFrame(() => { /** @ignore */ this.dispatchEvent(new CustomEvent('d2l-selection-provider-connected', { bubbles: true, composed: true })); @@ -69,24 +80,33 @@ export const SelectionMixin = superclass => class extends RtlMixin(superclass) { disconnectedCallback() { super.disconnectedCallback(); + if (this._observerController) this._observerController.hostDisconnected(); + if (this._selectablesController) this._selectablesController.hostDisconnected(); + if (this.selectionSingle) this.removeEventListener('keydown', this._handleRadioKeyDown); if (this.selectionSingle) this.removeEventListener('keyup', this._handleRadioKeyUp); this.removeEventListener('d2l-selection-change', this._handleSelectionChange); - this.removeEventListener('d2l-selection-observer-subscribe', this._handleSelectionObserverSubscribe); - this.removeEventListener('d2l-selection-input-subscribe', this._handleSelectionInputSubscribe); + } + + getController(controllerId) { + if (controllerId === 'observer') { + return this._observerController; + } else if (controllerId === 'input') { + return this._selectablesController; + } } getSelectionInfo() { let state = SelectionInfo.states.none; const keys = []; - this._selectionSelectables.forEach(selectable => { + this._selectablesController.subscribers.forEach(selectable => { if (selectable.selected) keys.push(selectable.key); if (selectable._indeterminate) state = SelectionInfo.states.some; }); if (keys.length > 0) { - if (keys.length === this._selectionSelectables.size) state = SelectionInfo.states.all; + if (keys.length === this._selectablesController.subscribers.size) state = SelectionInfo.states.all; else state = SelectionInfo.states.some; } @@ -96,27 +116,12 @@ export const SelectionMixin = superclass => class extends RtlMixin(superclass) { setSelectionForAll(selected) { if (this.selectionSingle && selected) return; - this._selectionSelectables.forEach(selectable => { + this._selectablesController.subscribers.forEach(selectable => { if (!!selectable.selected !== selected) { selectable.selected = selected; } }); - this._updateSelectionObservers(); - } - - subscribeObserver(target) { - if (this._selectionObservers.has(target)) return; - this._selectionObservers.set(target, target); - this._updateSelectionObservers(); - } - - unsubscribeObserver(target) { - this._selectionObservers.delete(target); - } - - unsubscribeSelectable(target) { - this._selectionSelectables.delete(target); - this._updateSelectionObservers(); + this._observerController.updateSubscribers(); } _handleRadioKeyDown(e) { @@ -133,7 +138,7 @@ export const SelectionMixin = superclass => class extends RtlMixin(superclass) { if (!e.composedPath()[0].classList.contains('d2l-selection-input-radio')) return; if (e.keyCode < keyCodes.LEFT || e.keyCode > keyCodes.DOWN) return; - const selectables = Array.from(this._selectionSelectables.values()); + const selectables = Array.from(this._selectablesController.subscribers.values()); let currentIndex = selectables.findIndex(selectable => selectable.selected); if (currentIndex === -1) currentIndex = 0; let newIndex; @@ -156,49 +161,31 @@ export const SelectionMixin = superclass => class extends RtlMixin(superclass) { _handleSelectionChange(e) { if (this.selectionSingle && e.detail.selected) { const target = e.composedPath().find(elem => elem.tagName === 'D2L-SELECTION-INPUT'); - this._selectionSelectables.forEach(selectable => { + this._selectablesController.subscribers.forEach(selectable => { if (selectable.selected && selectable !== target) selectable.selected = false; }); } - this._updateSelectionObservers(); + this._observerController.updateSubscribers(); } - _handleSelectionInputSubscribe(e) { - e.stopPropagation(); - e.detail.provider = this; - const target = e.composedPath()[0]; - if (this._selectionSelectables.has(target)) return; - this._selectionSelectables.set(target, target); - + _subscribeSelectable(target) { if (this.selectionSingle && target.selected) { // check invalid usage/state - make sure no others are selected - this._selectionSelectables.forEach(selectable => { + this._selectablesController.subscribers.forEach(selectable => { if (selectable.selected && selectable !== target) selectable.selected = false; }); } - this._updateSelectionObservers(); + this._observerController.updateSubscribers(); } - _handleSelectionObserverSubscribe(e) { - e.stopPropagation(); - e.detail.provider = this; - const target = e.composedPath()[0]; - this.subscribeObserver(target); + _unsubscribeSelectable() { + this._observerController.updateSubscribers(); } - _updateSelectionObservers() { - if (!this._selectionObservers || this._selectionObservers.size === 0) return; - - // debounce the updates for select-all case - if (this._updateObserversRequested) return; - - this._updateObserversRequested = true; - setTimeout(() => { - const info = this.getSelectionInfo(true); - this._selectionObservers.forEach(observer => observer.selectionInfo = info); - this._updateObserversRequested = false; - }, 0); + _updateObservers(observers) { + const info = this.getSelectionInfo(true); + observers.forEach(observer => observer.selectionInfo = info); } }; diff --git a/components/selection/selection-observer-mixin.js b/components/selection/selection-observer-mixin.js index 156d8233ae9..848f216d7cf 100644 --- a/components/selection/selection-observer-mixin.js +++ b/components/selection/selection-observer-mixin.js @@ -1,4 +1,4 @@ -import { cssEscape } from '../../helpers/dom.js'; +import { EventSubscriberController, ForPropertySubscriberController } from '../../helpers/subscriptionControllers.js'; import { SelectionInfo } from './selection-mixin.js'; export const SelectionObserverMixin = superclass => class extends superclass { @@ -15,68 +15,50 @@ export const SelectionObserverMixin = superclass => class extends superclass { * @ignore * @type {object} */ - selectionInfo: { type: Object }, - _provider: { type: Object, attribute: false } + selectionInfo: { type: Object } }; } constructor() { super(); this.selectionInfo = new SelectionInfo(); - this._provider = null; + + this._eventSubscriberController = new EventSubscriberController(this, {}, + { eventName: 'd2l-selection-observer-subscribe', controllerId: 'observer' } + ); + + this._forPropertySubscriberController = new ForPropertySubscriberController(this, + { onUnsubscribe: this._clearSelectionInfo.bind(this) }, + { forProperty: 'selectionFor', controllerId: 'observer' } + ); } connectedCallback() { super.connectedCallback(); - // delay subscription otherwise import/upgrade order can cause selection mixin to miss event - requestAnimationFrame(() => { - if (this.selectionFor) return; - - const evt = new CustomEvent('d2l-selection-observer-subscribe', { - bubbles: true, - composed: true, - detail: {} - }); - this.dispatchEvent(evt); - this._provider = evt.detail.provider; - }); + this._eventSubscriberController.hostConnected(); } disconnectedCallback() { super.disconnectedCallback(); - if (this._selectionForObserver) this._selectionForObserver.disconnect(); - if (this._provider) this._provider.unsubscribeObserver(this); + this._eventSubscriberController.hostDisconnected(); + this._forPropertySubscriberController.hostDisconnected(); } updated(changedProperties) { super.updated(changedProperties); - - if (!changedProperties.has('selectionFor')) return; - - if (this._selectionForObserver) this._selectionForObserver.disconnect(); - if (this._provider) this._provider.unsubscribeObserver(this); - - this._updateProvider(); - - this._selectionForObserver = new MutationObserver(() => { - this._updateProvider(); - }); - - this._selectionForObserver.observe(this.getRootNode(), { - childList: true, - subtree: true - }); + this._forPropertySubscriberController.hostUpdated(changedProperties); } - _updateProvider() { - const selectionComponent = this.getRootNode().querySelector(`#${cssEscape(this.selectionFor)}`); - if (this._provider === selectionComponent) return; + _clearSelectionInfo() { + this.selectionInfo = new SelectionInfo(); + } - this._provider = selectionComponent; - if (this._provider) { - this._provider.subscribeObserver(this); + _getSelectionProvider() { + if (this.selectionFor) { + // Selection components currently only support one provider id in selectionFor + return this._forPropertySubscriberController.providers[0]; } else { - this.selectionInfo = new SelectionInfo(); + return this._eventSubscriberController.provider; } } }; diff --git a/components/selection/selection-select-all.js b/components/selection/selection-select-all.js index 17c626d5c64..68d59da4e85 100644 --- a/components/selection/selection-select-all.js +++ b/components/selection/selection-select-all.js @@ -39,7 +39,8 @@ class SelectAll extends LocalizeCoreElement(SelectionObserverMixin(LitElement)) } render() { - if (this._provider && this._provider.selectionSingle) return; + const provider = this._getSelectionProvider(); + if (provider && provider.selectionSingle) return; const summary = (this.selectionInfo.state === SelectionInfo.states.none ? this.localize('components.selection.select-all') : this.localize('components.selection.selected', 'count', this.selectionInfo.keys.length)); @@ -62,7 +63,8 @@ class SelectAll extends LocalizeCoreElement(SelectionObserverMixin(LitElement)) } _handleCheckboxChange(e) { - if (this._provider) this._provider.setSelectionForAll(e.target.checked); + const provider = this._getSelectionProvider(); + if (provider) provider.setSelectionForAll(e.target.checked); } } diff --git a/components/selection/selection-summary.js b/components/selection/selection-summary.js index d46f7f1aef4..889f5bd0ca8 100644 --- a/components/selection/selection-summary.js +++ b/components/selection/selection-summary.js @@ -35,7 +35,8 @@ class Summary extends LocalizeCoreElement(SelectionObserverMixin(LitElement)) { } render() { - if (this._provider && this._provider.selectionSingle) return; + const provider = this._getSelectionProvider(); + if (provider && provider.selectionSingle) return; const summary = (this.selectionInfo.state === SelectionInfo.states.none && this.noSelectionText ? this.noSelectionText : this.localize('components.selection.selected', 'count', this.selectionInfo.keys.length)); diff --git a/components/validation/validation-custom-mixin.js b/components/validation/validation-custom-mixin.js index f7dfa659d6f..489f6b4c45b 100644 --- a/components/validation/validation-custom-mixin.js +++ b/components/validation/validation-custom-mixin.js @@ -1,4 +1,4 @@ -import { isCustomFormElement } from '../form/form-helper.js'; +import { EventSubscriberController, ForPropertySubscriberController } from '../../helpers/subscriptionControllers.js'; export const ValidationCustomMixin = superclass => class extends superclass { @@ -11,60 +11,49 @@ export const ValidationCustomMixin = superclass => class extends superclass { constructor() { super(); - this._forElement = null; + + this._eventSubscriberController = new EventSubscriberController(this, {}, + { eventName: 'd2l-validation-custom-connected' } + ); + + this._forPropertySubscriberController = new ForPropertySubscriberController(this, + { onUnsubscribe: this._onUnsubscribe.bind(this) }, + { forProperty: 'for' } + ); } get forElement() { - return this._forElement; + if (this.for) { + // Validation custom components only support one for element + return this._forPropertySubscriberController.providers.length > 0 ? this._forPropertySubscriberController.providers[0] : null; + } else { + return this._eventSubscriberController.provider; + } } connectedCallback() { super.connectedCallback(); - this._updateForElement(); - this.dispatchEvent(new CustomEvent('d2l-validation-custom-connected', { bubbles: true })); + this._eventSubscriberController.hostConnected(); } disconnectedCallback() { super.disconnectedCallback(); - if (isCustomFormElement(this._forElement)) { - this._forElement.validationCustomDisconnected(this); - } - this._forElement = null; - this.dispatchEvent(new CustomEvent('d2l-validation-custom-disconnected')); + this._eventSubscriberController.hostDisconnected(); + this._forPropertySubscriberController.hostDisconnected(); } updated(changedProperties) { super.updated(changedProperties); - - changedProperties.forEach((_, prop) => { - if (prop === 'for') { - this._updateForElement(); - } - }); + this._forPropertySubscriberController.hostUpdated(changedProperties); } async validate() { throw new Error('ValidationCustomMixin requires validate to be overridden'); } - _updateForElement() { - const oldForElement = this._forElement; - if (this.for) { - const root = this.getRootNode(); - this._forElement = root.getElementById(this.for); - if (!this._forElement) { - throw new Error(`validation-custom failed to find element with id ${this.for}`); - } - } else { - this._forElement = null; - } - if (this._forElement !== oldForElement) { - if (isCustomFormElement(oldForElement)) { - oldForElement.validationCustomDisconnected(this); - } - if (isCustomFormElement(this._forElement)) { - this._forElement.validationCustomConnected(this); - } + _onUnsubscribe() { + if (this._forPropertySubscriberController.provider.length === 0) { + throw new Error(`validation-custom failed to find element with id ${this.for}`); } } diff --git a/helpers/subscriptionControllers.js b/helpers/subscriptionControllers.js new file mode 100644 index 00000000000..cebef5886ac --- /dev/null +++ b/helpers/subscriptionControllers.js @@ -0,0 +1,174 @@ +import { cssEscape } from '../../helpers/dom.js'; + +export class ProviderController { + + constructor(host, callbacks, options) { + this._host = host; + this._callbacks = callbacks || {}; + this._eventName = options && options.eventName; + this.subscribers = new Map(); + + this._handleSubscribe = this._handleSubscribe.bind(this); + } + + hostConnected() { + if (this._eventName) this._host.addEventListener(this._eventName, this._handleSubscribe); + } + + hostDisconnected() { + if (this._eventName) this._host.removeEventListener(this._eventName, this._handleSubscribe); + } + + subscribe(target) { + if (this.subscribers.has(target)) return; + this.subscribers.set(target, target); + this.updateSubscribers(); + if (this._callbacks.onSubscribe) this._callbacks.onSubscribe(target); + } + + unsubscribe(target) { + this.subscribers.delete(target); + if (this._callbacks.onUnsubscribe) this._callbacks.onUnsubscribe(target); + } + + updateSubscribers() { + if (!this.subscribers || this.subscribers.size === 0) return; + if (!this._callbacks.updateSubscribers) return; + + // debounce the updates + if (this._updateSubscribersRequested) return; + + this._updateSubscribersRequested = true; + setTimeout(() => { + this._callbacks.updateSubscribers(this.subscribers); + this._updateSubscribersRequested = false; + }, 0); + } + + _handleSubscribe(e) { + e.stopPropagation(); + e.detail.provider = this._host; + const target = e.composedPath()[0]; + this.subscribe(target); + } +} + +export class EventSubscriberController { + + constructor(host, callbacks, options) { + this._host = host; + this._callbacks = callbacks || {}; + this._eventName = options && options.eventName; + this._controllerId = options && options.controllerId; + this._provider = null; + } + + get provider() { + return this._provider; + } + + hostConnected() { + /* Do we try not to fire this event if someone wants to use the other provider instead based on set attributes? + * That's really the selection-observer-mixin's problem, not these cotnrollers, who should assume you want to use them... + */ + + // delay subscription otherwise import/upgrade order can cause selection mixin to miss event + requestAnimationFrame(() => { + const evt = new CustomEvent(this._eventName, { + bubbles: true, + composed: true, + detail: {} + }); + this._host.dispatchEvent(evt); + this._provider = evt.detail.provider; + if (this._callbacks.onSubscribe) this._callbacks.onSubscribe(evt.detail.provider); + }); + } + + hostDisconnected() { + if (this._provider) this._provider.getController(this._controllerId).unsubscribe(this._host); + } + +} + +export class ForPropertySubscriberController { + + constructor(host, callbacks, options) { + this._host = host; + this._callbacks = callbacks || {}; + this._forPropertyName = options && options.forProperty; + this._controllerId = options && options.controllerId; + this._providers = new Map(); + } + + get providers() { + return Array.from(this._providers.values()); + } + + hostDisconnected() { + if (this._forObserver) this._forObserver.disconnect(); + this._providers.forEach(provider => { + provider.getController(this._controllerId).unsubscribe(this._host); + }); + } + + hostUpdated(changedProperties) { + if (!changedProperties.has(this._forPropertyName)) return; + + if (this._forObserver) this._forObserver.disconnect(); + this._providers.forEach(provider => { + provider.getController(this._controllerId).unsubscribe(this._host); + }); + this._providers = new Map(); + + this._updateProviders(); + + this._forObserver = new MutationObserver(() => { + this._updateProviders(); + }); + + this._forObserver.observe(this._host.getRootNode(), { + childList: true, + subtree: true + }); + } + + _updateProvider(providerId, elapsedTime) { + const providerComponent = this._host.getRootNode().querySelector(`#${cssEscape(providerId)}`); + if (!providerComponent && this._callbacks.onError) { + if (elapsedTime < 3000) { + setTimeout(() => { + this._updateProvider(providerId, elapsedTime + 100); + }, 100); + } else { + this._callbacks.onError(providerId); + } + } + + if (this._providers.get(providerId) === providerComponent) return; + + if (providerComponent) { + providerComponent.getController(this._controllerId).subscribe(this._host); + this._providers.set(providerId, providerComponent); + if (this._callbacks.onSubscribe) this._callbacks.onSubscribe(providerComponent); + } else { + this._providers.delete(providerId); + if (this._callbacks.onUnsubscribe) this._callbacks.onUnsubscribe(providerComponent); + } + } + + _updateProviders() { + let providerIds = this._host[this._forPropertyName]; + if (!providerIds) { + // callback for no provider ids? + return; + } + + if (typeof(providerIds) === 'string') providerIds = [providerIds]; + + providerIds.forEach(providerId => { + this._updateProvider(providerId, 0); + }); + } + +}