diff --git a/src/core/context.ts b/src/core/context.ts index 0190ad9e..e1187add 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -8,14 +8,17 @@ import { Schema } from "./schema" import { Scope } from "./scope" import { ValueObserver } from "./value_observer" import { TargetObserver, TargetObserverDelegate } from "./target_observer" +import { OutletObserver, OutletObserverDelegate } from "./outlet_observer" +import { namespaceCamelize } from "./string_helpers" -export class Context implements ErrorHandler, TargetObserverDelegate { +export class Context implements ErrorHandler, TargetObserverDelegate, OutletObserverDelegate { readonly module: Module readonly scope: Scope readonly controller: Controller private bindingObserver: BindingObserver private valueObserver: ValueObserver private targetObserver: TargetObserver + private outletObserver: OutletObserver constructor(module: Module, scope: Scope) { this.module = module @@ -24,6 +27,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.bindingObserver = new BindingObserver(this, this.dispatcher) this.valueObserver = new ValueObserver(this, this.controller) this.targetObserver = new TargetObserver(this, this) + this.outletObserver = new OutletObserver(this, this) try { this.controller.initialize() @@ -37,6 +41,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.bindingObserver.start() this.valueObserver.start() this.targetObserver.start() + this.outletObserver.start() try { this.controller.connect() @@ -46,6 +51,10 @@ export class Context implements ErrorHandler, TargetObserverDelegate { } } + refresh() { + this.outletObserver.refresh() + } + disconnect() { try { this.controller.disconnect() @@ -54,6 +63,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.handleError(error, "disconnecting controller") } + this.outletObserver.stop() this.targetObserver.stop() this.valueObserver.stop() this.bindingObserver.stop() @@ -109,6 +119,16 @@ export class Context implements ErrorHandler, TargetObserverDelegate { this.invokeControllerMethod(`${name}TargetDisconnected`, element) } + // Outlet observer delegate + + outletConnected(outlet: Controller, element: Element, name: string) { + this.invokeControllerMethod(`${namespaceCamelize(name)}OutletConnected`, outlet, element) + } + + outletDisconnected(outlet: Controller, element: Element, name: string) { + this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element) + } + // Private invokeControllerMethod(methodName: string, ...args: any[]) { diff --git a/src/core/controller.ts b/src/core/controller.ts index f3db251a..ae2586db 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -2,14 +2,21 @@ import { Application } from "./application" import { ClassPropertiesBlessing } from "./class_properties" import { Constructor } from "./constructor" import { Context } from "./context" +import { OutletPropertiesBlessing } from "./outlet_properties" import { TargetPropertiesBlessing } from "./target_properties" import { ValuePropertiesBlessing, ValueDefinitionMap } from "./value_properties" export type ControllerConstructor = Constructor export class Controller { - static blessings = [ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing] + static blessings = [ + ClassPropertiesBlessing, + TargetPropertiesBlessing, + ValuePropertiesBlessing, + OutletPropertiesBlessing, + ] static targets: string[] = [] + static outlets: string[] = [] static values: ValueDefinitionMap = {} static get shouldLoad() { @@ -46,6 +53,10 @@ export class Controller { return this.scope.targets } + get outlets() { + return this.scope.outlets + } + get classes() { return this.scope.classes } diff --git a/src/core/outlet_observer.ts b/src/core/outlet_observer.ts new file mode 100644 index 00000000..0208129f --- /dev/null +++ b/src/core/outlet_observer.ts @@ -0,0 +1,173 @@ +import { Multimap } from "../multimap" +import { SelectorObserver, SelectorObserverDelegate } from "../mutation-observers" +import { Context } from "./context" +import { Controller } from "./controller" + +import { readInheritableStaticArrayValues } from "./inheritable_statics" + +type SelectorObserverDetails = { outletName: string } + +export interface OutletObserverDelegate { + outletConnected(outlet: Controller, element: Element, outletName: string): void + outletDisconnected(outlet: Controller, element: Element, outletName: string): void +} + +export class OutletObserver implements SelectorObserverDelegate { + readonly context: Context + readonly delegate: OutletObserverDelegate + readonly outletsByName: Multimap + readonly outletElementsByName: Multimap + private selectorObserverMap: Map + + constructor(context: Context, delegate: OutletObserverDelegate) { + this.context = context + this.delegate = delegate + this.outletsByName = new Multimap() + this.outletElementsByName = new Multimap() + this.selectorObserverMap = new Map() + } + + start() { + if (this.selectorObserverMap.size === 0) { + this.outletDefinitions.forEach((outletName) => { + const selector = this.selector(outletName) + const details: SelectorObserverDetails = { outletName } + + if (selector) { + this.selectorObserverMap.set(outletName, new SelectorObserver(document.body, selector, this, details)) + } + }) + + this.selectorObserverMap.forEach((observer) => observer.start()) + } + + this.dependentContexts.forEach((context) => context.refresh()) + } + + stop() { + if (this.selectorObserverMap.size > 0) { + this.disconnectAllOutlets() + this.selectorObserverMap.forEach((observer) => observer.stop()) + this.selectorObserverMap.clear() + } + } + + refresh() { + this.selectorObserverMap.forEach((observer) => observer.refresh()) + } + + // Selector observer delegate + + selectorMatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) { + const outlet = this.getOutlet(element, outletName) + + if (outlet) { + this.connectOutlet(outlet, element, outletName) + } + } + + selectorUnmatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) { + const outlet = this.getOutletFromMap(element, outletName) + + if (outlet) { + this.disconnectOutlet(outlet, element, outletName) + } + } + + selectorMatchElement(element: Element, { outletName }: SelectorObserverDetails) { + return ( + this.hasOutlet(element, outletName) && + element.matches(`[${this.context.application.schema.controllerAttribute}~=${outletName}]`) + ) + } + + // Outlet management + + connectOutlet(outlet: Controller, element: Element, outletName: string) { + if (!this.outletElementsByName.has(outletName, element)) { + this.outletsByName.add(outletName, outlet) + this.outletElementsByName.add(outletName, element) + this.selectorObserverMap.get(outletName)?.pause(() => this.delegate.outletConnected(outlet, element, outletName)) + } + } + + disconnectOutlet(outlet: Controller, element: Element, outletName: string) { + if (this.outletElementsByName.has(outletName, element)) { + this.outletsByName.delete(outletName, outlet) + this.outletElementsByName.delete(outletName, element) + this.selectorObserverMap + .get(outletName) + ?.pause(() => this.delegate.outletDisconnected(outlet, element, outletName)) + } + } + + disconnectAllOutlets() { + for (const outletName of this.outletElementsByName.keys) { + for (const element of this.outletElementsByName.getValuesForKey(outletName)) { + for (const outlet of this.outletsByName.getValuesForKey(outletName)) { + this.disconnectOutlet(outlet, element, outletName) + } + } + } + } + + // Private + + private selector(outletName: string) { + return this.scope.outlets.getSelectorForOutletName(outletName) + } + + private get outletDependencies() { + const dependencies = new Multimap() + + this.router.modules.forEach((module) => { + const constructor = module.definition.controllerConstructor + const outlets = readInheritableStaticArrayValues(constructor, "outlets") + + outlets.forEach((outlet) => dependencies.add(outlet, module.identifier)) + }) + + return dependencies + } + + private get outletDefinitions() { + return this.outletDependencies.getKeysForValue(this.identifier) + } + + private get dependentControllerIdentifiers() { + return this.outletDependencies.getValuesForKey(this.identifier) + } + + private get dependentContexts() { + const identifiers = this.dependentControllerIdentifiers + return this.router.contexts.filter((context) => identifiers.includes(context.identifier)) + } + + private hasOutlet(element: Element, outletName: string) { + return !!this.getOutlet(element, outletName) || !!this.getOutletFromMap(element, outletName) + } + + private getOutlet(element: Element, outletName: string) { + return this.application.getControllerForElementAndIdentifier(element, outletName) + } + + private getOutletFromMap(element: Element, outletName: string) { + return this.outletsByName.getValuesForKey(outletName).find((outlet) => outlet.element === element) + } + + private get scope() { + return this.context.scope + } + + private get identifier() { + return this.context.identifier + } + + private get application() { + return this.context.application + } + + private get router() { + return this.application.router + } +} diff --git a/src/core/outlet_properties.ts b/src/core/outlet_properties.ts new file mode 100644 index 00000000..3fe7928d --- /dev/null +++ b/src/core/outlet_properties.ts @@ -0,0 +1,83 @@ +import { Constructor } from "./constructor" +import { Controller } from "./controller" +import { readInheritableStaticArrayValues } from "./inheritable_statics" +import { capitalize, namespaceCamelize } from "./string_helpers" + +export function OutletPropertiesBlessing(constructor: Constructor) { + const outlets = readInheritableStaticArrayValues(constructor, "outlets") + return outlets.reduce((properties: any, outletDefinition: any) => { + return Object.assign(properties, propertiesForOutletDefinition(outletDefinition)) + }, {} as PropertyDescriptorMap) +} + +function propertiesForOutletDefinition(name: string) { + const camelizedName = namespaceCamelize(name) + + return { + [`${camelizedName}Outlet`]: { + get(this: Controller) { + const outlet = this.outlets.find(name) + + if (outlet) { + const outletController = this.application.getControllerForElementAndIdentifier(outlet, name) + if (outletController) { + return outletController + } else { + throw new Error( + `Missing "data-controller=${name}" attribute on outlet element for "${this.identifier}" controller` + ) + } + } + + throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`) + }, + }, + + [`${camelizedName}Outlets`]: { + get(this: Controller) { + const outlets = this.outlets.findAll(name) + + if (outlets.length > 0) { + return outlets + .map((outlet: Element) => { + const controller = this.application.getControllerForElementAndIdentifier(outlet, name) + if (controller) { + return controller + } else { + console.warn( + `The provided outlet element is missing the outlet controller "${name}" for "${this.identifier}"`, + outlet + ) + } + }) + .filter((controller) => controller) as Controller[] + } + + return [] + }, + }, + + [`${camelizedName}OutletElement`]: { + get(this: Controller) { + const outlet = this.outlets.find(name) + if (outlet) { + return outlet + } else { + throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`) + } + }, + }, + + [`${camelizedName}OutletElements`]: { + get(this: Controller) { + return this.outlets.findAll(name) + }, + }, + + [`has${capitalize(camelizedName)}Outlet`]: { + get(this: Controller) { + return this.outlets.has(name) + }, + }, + } +} diff --git a/src/core/outlet_set.ts b/src/core/outlet_set.ts new file mode 100644 index 00000000..b8ca77b4 --- /dev/null +++ b/src/core/outlet_set.ts @@ -0,0 +1,71 @@ +import { Scope } from "./scope" + +export class OutletSet { + readonly scope: Scope + readonly controllerElement: Element + + constructor(scope: Scope, controllerElement: Element) { + this.scope = scope + this.controllerElement = controllerElement + } + + get element() { + return this.scope.element + } + + get identifier() { + return this.scope.identifier + } + + get schema() { + return this.scope.schema + } + + has(outletName: string) { + return this.find(outletName) != null + } + + find(...outletNames: string[]) { + return outletNames.reduce( + (outlet, outletName) => outlet || this.findOutlet(outletName), + undefined as Element | undefined + ) + } + + findAll(...outletNames: string[]) { + return outletNames.reduce( + (outlets, outletName) => [...outlets, ...this.findAllOutlets(outletName)], + [] as Element[] + ) + } + + getSelectorForOutletName(outletName: string) { + const attributeName = this.schema.outletAttributeForScope(this.identifier, outletName) + return this.controllerElement.getAttribute(attributeName) + } + + private findOutlet(outletName: string) { + const selector = this.getSelectorForOutletName(outletName) + if (selector) return this.findElement(selector, outletName) + } + + private findAllOutlets(outletName: string) { + const selector = this.getSelectorForOutletName(outletName) + return selector ? this.findAllElements(selector, outletName) : [] + } + + private findElement(selector: string, outletName: string): Element | undefined { + const elements = this.scope.queryElements(selector) + return elements.filter((element) => this.matchesElement(element, selector, outletName))[0] + } + + private findAllElements(selector: string, outletName: string): Element[] { + const elements = this.scope.queryElements(selector) + return elements.filter((element) => this.matchesElement(element, selector, outletName)) + } + + private matchesElement(element: Element, selector: string, outletName: string): boolean { + const controllerAttribute = element.getAttribute(this.scope.schema.controllerAttribute) || "" + return element.matches(selector) && controllerAttribute.split(" ").includes(outletName) + } +} diff --git a/src/core/schema.ts b/src/core/schema.ts index 6b04eb26..c327ed4d 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -3,6 +3,7 @@ export interface Schema { actionAttribute: string targetAttribute: string targetAttributeForScope(identifier: string): string + outletAttributeForScope(identifier: string, outlet: string): string } export const defaultSchema: Schema = { @@ -10,4 +11,5 @@ export const defaultSchema: Schema = { actionAttribute: "data-action", targetAttribute: "data-target", targetAttributeForScope: (identifier) => `data-${identifier}-target`, + outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`, } diff --git a/src/core/scope.ts b/src/core/scope.ts index 9e70f8e0..de302807 100644 --- a/src/core/scope.ts +++ b/src/core/scope.ts @@ -5,12 +5,14 @@ import { Logger } from "./logger" import { Schema } from "./schema" import { attributeValueContainsToken } from "./selectors" import { TargetSet } from "./target_set" +import { OutletSet } from "./outlet_set" export class Scope { readonly schema: Schema readonly element: Element readonly identifier: string readonly guide: Guide + readonly outlets: OutletSet readonly targets = new TargetSet(this) readonly classes = new ClassMap(this) readonly data = new DataMap(this) @@ -20,6 +22,7 @@ export class Scope { this.element = element this.identifier = identifier this.guide = new Guide(logger) + this.outlets = new OutletSet(this.documentScope, element) } findElement(selector: string): Element | undefined { @@ -37,11 +40,21 @@ export class Scope { return element.closest(this.controllerSelector) === this.element } - private queryElements(selector: string): Element[] { + queryElements(selector: string): Element[] { return Array.from(this.element.querySelectorAll(selector)) } private get controllerSelector(): string { return attributeValueContainsToken(this.schema.controllerAttribute, this.identifier) } + + private get isDocumentScope() { + return this.element === document.documentElement + } + + private get documentScope(): Scope { + return this.isDocumentScope + ? this + : new Scope(this.schema, document.documentElement, this.identifier, this.guide.logger) + } } diff --git a/src/core/string_helpers.ts b/src/core/string_helpers.ts index 17c9c393..5d74e639 100644 --- a/src/core/string_helpers.ts +++ b/src/core/string_helpers.ts @@ -2,6 +2,10 @@ export function camelize(value: string) { return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()) } +export function namespaceCamelize(value: string) { + return camelize(value.replace(/--/g, "-").replace(/__/g, "_")) +} + export function capitalize(value: string) { return value.charAt(0).toUpperCase() + value.slice(1) } diff --git a/src/mutation-observers/index.ts b/src/mutation-observers/index.ts index 41ba74b1..f029b286 100644 --- a/src/mutation-observers/index.ts +++ b/src/mutation-observers/index.ts @@ -1,5 +1,6 @@ export * from "./attribute_observer" export * from "./element_observer" +export * from "./selector_observer" export * from "./string_map_observer" export * from "./token_list_observer" export * from "./value_list_observer" diff --git a/src/mutation-observers/selector_observer.ts b/src/mutation-observers/selector_observer.ts new file mode 100644 index 00000000..d18d09ec --- /dev/null +++ b/src/mutation-observers/selector_observer.ts @@ -0,0 +1,95 @@ +import { ElementObserver, ElementObserverDelegate } from "./element_observer" +import { Multimap } from "../multimap" + +export interface SelectorObserverDelegate { + selectorMatched(element: Element, selector: string, details: object): void + selectorUnmatched(element: Element, selector: string, details: object): void + selectorMatchElement?(element: Element, details: object): boolean +} + +export class SelectorObserver implements ElementObserverDelegate { + private selector: string + private elementObserver: ElementObserver + private delegate: SelectorObserverDelegate + private matchesByElement: Multimap + private details: object + + constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object = {}) { + this.selector = selector + this.details = details + this.elementObserver = new ElementObserver(element, this) + this.delegate = delegate + this.matchesByElement = new Multimap() + } + + get started(): boolean { + return this.elementObserver.started + } + + start() { + this.elementObserver.start() + } + + pause(callback: () => void) { + this.elementObserver.pause(callback) + } + + stop() { + this.elementObserver.stop() + } + + refresh() { + this.elementObserver.refresh() + } + + get element(): Element { + return this.elementObserver.element + } + + // Element observer delegate + + matchElement(element: Element): boolean { + const matches = element.matches(this.selector) + + if (this.delegate.selectorMatchElement) { + return matches && this.delegate.selectorMatchElement(element, this.details) + } + + return matches + } + + matchElementsInTree(tree: Element): Element[] { + const match = this.matchElement(tree) ? [tree] : [] + const matches = Array.from(tree.querySelectorAll(this.selector)).filter((match) => this.matchElement(match)) + return match.concat(matches) + } + + elementMatched(element: Element) { + this.selectorMatched(element) + } + + elementUnmatched(element: Element) { + this.selectorUnmatched(element) + } + + elementAttributeChanged(element: Element, _attributeName: string) { + const matches = this.matchElement(element) + const matchedBefore = this.matchesByElement.has(this.selector, element) + + if (!matches && matchedBefore) { + this.selectorUnmatched(element) + } + } + + private selectorMatched(element: Element) { + if (this.delegate.selectorMatched) { + this.delegate.selectorMatched(element, this.selector, this.details) + this.matchesByElement.add(this.selector, element) + } + } + + private selectorUnmatched(element: Element) { + this.delegate.selectorUnmatched(element, this.selector, this.details) + this.matchesByElement.delete(this.selector, element) + } +} diff --git a/src/tests/controllers/outlet_controller.ts b/src/tests/controllers/outlet_controller.ts new file mode 100644 index 00000000..c3a5b840 --- /dev/null +++ b/src/tests/controllers/outlet_controller.ts @@ -0,0 +1,79 @@ +import { Controller } from "../../core/controller" + +class BaseOutletController extends Controller { + static outlets = ["alpha"] + + alphaOutlet!: Controller | null + alphaOutlets!: Controller[] + alphaOutletElement!: Element | null + alphaOutletElements!: Element[] + hasAlphaOutlet!: boolean +} + +export class OutletController extends BaseOutletController { + static classes = ["connected", "disconnected"] + static outlets = ["beta", "gamma", "delta", "omega", "namespaced--epsilon"] + + static values = { + alphaOutletConnectedCallCount: Number, + alphaOutletDisconnectedCallCount: Number, + betaOutletConnectedCallCount: Number, + betaOutletDisconnectedCallCount: Number, + namespacedEpsilonOutletConnectedCallCount: Number, + namespacedEpsilonOutletDisconnectedCallCount: Number, + } + + betaOutlet!: Controller | null + betaOutlets!: Controller[] + betaOutletElement!: Element | null + betaOutletElements!: Element[] + hasBetaOutlet!: boolean + + namespacedEpsilonOutlet!: Controller | null + namespacedEpsilonOutlets!: Controller[] + namespacedEpsilonOutletElement!: Element | null + namespacedEpsilonOutletElements!: Element[] + hasNamespacedEpsilonOutlet!: boolean + + hasConnectedClass!: boolean + hasDisconnectedClass!: boolean + connectedClass!: string + disconnectedClass!: string + + alphaOutletConnectedCallCountValue = 0 + alphaOutletDisconnectedCallCountValue = 0 + betaOutletConnectedCallCountValue = 0 + betaOutletDisconnectedCallCountValue = 0 + namespacedEpsilonOutletConnectedCallCountValue = 0 + namespacedEpsilonOutletDisconnectedCallCountValue = 0 + + alphaOutletConnected(_outlet: Controller, element: Element) { + if (this.hasConnectedClass) element.classList.add(this.connectedClass) + this.alphaOutletConnectedCallCountValue++ + } + + alphaOutletDisconnected(_outlet: Controller, element: Element) { + if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) + this.alphaOutletDisconnectedCallCountValue++ + } + + betaOutletConnected(_outlet: Controller, element: Element) { + if (this.hasConnectedClass) element.classList.add(this.connectedClass) + this.betaOutletConnectedCallCountValue++ + } + + betaOutletDisconnected(_outlet: Controller, element: Element) { + if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) + this.betaOutletDisconnectedCallCountValue++ + } + + namespacedEpsilonOutletConnected(_outlet: Controller, element: Element) { + if (this.hasConnectedClass) element.classList.add(this.connectedClass) + this.namespacedEpsilonOutletConnectedCallCountValue++ + } + + namespacedEpsilonOutletDisconnected(_outlet: Controller, element: Element) { + if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass) + this.namespacedEpsilonOutletDisconnectedCallCountValue++ + } +} diff --git a/src/tests/modules/core/outlet_tests.ts b/src/tests/modules/core/outlet_tests.ts new file mode 100644 index 00000000..0ff686d8 --- /dev/null +++ b/src/tests/modules/core/outlet_tests.ts @@ -0,0 +1,309 @@ +import { ControllerTestCase } from "../../cases/controller_test_case" +import { OutletController } from "../../controllers/outlet_controller" + +export default class OutletTests extends ControllerTestCase(OutletController) { + fixtureHTML = ` +
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+
+ ` + get identifiers() { + return ["test", "alpha", "beta", "gamma", "delta", "omega", "namespaced--epsilon"] + } + + "test OutletSet#find"() { + this.assert.equal(this.controller.outlets.find("alpha"), this.findElement("#alpha1")) + this.assert.equal(this.controller.outlets.find("beta"), this.findElement("#beta1")) + this.assert.equal(this.controller.outlets.find("delta"), this.findElement("#delta1")) + this.assert.equal(this.controller.outlets.find("namespaced--epsilon"), this.findElement("#epsilon1")) + } + + "test OutletSet#findAll"() { + this.assert.deepEqual(this.controller.outlets.findAll("alpha"), this.findElements("#alpha1", "#alpha2")) + this.assert.deepEqual(this.controller.outlets.findAll("beta"), this.findElements("#beta1", "#beta2")) + this.assert.deepEqual( + this.controller.outlets.findAll("namespaced--epsilon"), + this.findElements("#epsilon1", "#epsilon2") + ) + } + + "test OutletSet#findAll with multiple arguments"() { + this.assert.deepEqual( + this.controller.outlets.findAll("alpha", "beta", "namespaced--epsilon"), + this.findElements("#alpha1", "#alpha2", "#beta1", "#beta2", "#epsilon1", "#epsilon2") + ) + } + + "test OutletSet#has"() { + this.assert.equal(this.controller.outlets.has("alpha"), true) + this.assert.equal(this.controller.outlets.has("beta"), true) + this.assert.equal(this.controller.outlets.has("gamma"), false) + this.assert.equal(this.controller.outlets.has("delta"), true) + this.assert.equal(this.controller.outlets.has("omega"), false) + this.assert.equal(this.controller.outlets.has("namespaced--epsilon"), true) + } + + "test OutletSet#has when attribute gets added later"() { + this.assert.equal(this.controller.outlets.has("gamma"), false) + this.controller.element.setAttribute(`data-${this.identifier}-gamma-outlet`, ".gamma") + this.assert.equal(this.controller.outlets.has("gamma"), true) + } + + "test OutletSet#has when no element with selector exists"() { + this.controller.element.setAttribute(`data-${this.identifier}-gamma-outlet`, "#doesntexist") + this.assert.equal(this.controller.outlets.has("gamma"), false) + } + + "test OutletSet#has when selector matches but element doesn't have the right controller"() { + this.controller.element.setAttribute(`data-${this.identifier}-gamma-outlet`, ".alpha") + this.assert.equal(this.controller.outlets.has("gamma"), false) + } + + "test linked outlet properties"() { + const element = this.findElement("#beta1") + const betaOutlet = this.controller.application.getControllerForElementAndIdentifier(element, "beta") + this.assert.equal(this.controller.betaOutlet, betaOutlet) + this.assert.equal(this.controller.betaOutletElement, element) + + const elements = this.findElements("#beta1", "#beta2") + const betaOutlets = elements.map((element) => + this.controller.application.getControllerForElementAndIdentifier(element, "beta") + ) + this.assert.deepEqual(this.controller.betaOutlets, betaOutlets) + this.assert.deepEqual(this.controller.betaOutletElements, elements) + + this.assert.equal(this.controller.hasBetaOutlet, true) + } + + "test inherited linked outlet properties"() { + const element = this.findElement("#alpha1") + const alphaOutlet = this.controller.application.getControllerForElementAndIdentifier(element, "alpha") + this.assert.equal(this.controller.alphaOutlet, alphaOutlet) + this.assert.equal(this.controller.alphaOutletElement, element) + + const elements = this.findElements("#alpha1", "#alpha2") + const alphaOutlets = elements.map((element) => + this.controller.application.getControllerForElementAndIdentifier(element, "alpha") + ) + this.assert.deepEqual(this.controller.alphaOutlets, alphaOutlets) + this.assert.deepEqual(this.controller.alphaOutletElements, elements) + } + + "test singular linked outlet property throws an error when no outlet is found"() { + this.findElements("#alpha1", "#alpha2").forEach((e) => { + e.removeAttribute("id") + e.removeAttribute("class") + e.removeAttribute("data-controller") + }) + + this.assert.equal(this.controller.hasAlphaOutlet, false) + this.assert.equal(this.controller.alphaOutlets.length, 0) + this.assert.equal(this.controller.alphaOutletElements.length, 0) + this.assert.throws(() => this.controller.alphaOutlet) + this.assert.throws(() => this.controller.alphaOutletElement) + } + + "test outlet connected callback fires"() { + const alphaOutlets = this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("connected")) + + this.assert.equal(alphaOutlets.length, 2) + this.assert.equal(this.controller.alphaOutletConnectedCallCountValue, 2) + } + + "test outlet connected callback fires for namespaced outlets"() { + const epsilonOutlets = this.controller.namespacedEpsilonOutletElements.filter((outlet) => + outlet.classList.contains("connected") + ) + this.assert.equal(epsilonOutlets.length, 2) + this.assert.equal(this.controller.namespacedEpsilonOutletConnectedCallCountValue, 2) + } + + async "test outlet connected callback when element is inserted"() { + const betaOutletElement = document.createElement("div") + betaOutletElement.setAttribute("class", "beta") + betaOutletElement.setAttribute("data-controller", "beta") + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) + + this.controller.element.appendChild(betaOutletElement) + await this.nextFrame + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) + this.assert.ok( + betaOutletElement.classList.contains("connected"), + `expected "${betaOutletElement.className}" to contain "connected"` + ) + this.assert.ok(betaOutletElement.isConnected, "element is present in document") + + this.findElement("#container").appendChild(betaOutletElement.cloneNode(true)) + await this.nextFrame + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 4) + } + + async "test outlet connected callback when present element adds matching outlet selector attribute"() { + const element = this.findElement("#beta3") + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) + + element.setAttribute("data-controller", "beta") + element.classList.add("beta") + await this.nextFrame + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) + this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) + this.assert.ok(element.isConnected, "element is still present in document") + } + + async "test outlet connected callback when present element already has connected controller and adds matching outlet selector attribute"() { + const element = this.findElement("#beta4") + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) + + element.classList.add("beta") + await this.nextFrame + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) + this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) + this.assert.ok(element.isConnected, "element is still present in document") + } + + async "test outlet connect callback when an outlet present in the document adds a matching data-controller attribute"() { + const element = this.findElement("#beta5") + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 2) + + element.setAttribute(`data-controller`, "beta") + await this.nextFrame + + this.assert.equal(this.controller.betaOutletConnectedCallCountValue, 3) + this.assert.ok(element.classList.contains("connected"), `expected "${element.className}" to contain "connected"`) + this.assert.ok(element.isConnected, "element is still present in document") + } + + async "test outlet disconnected callback fires when calling disconnect() on the controller"() { + this.assert.equal( + this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("disconnected")).length, + 0 + ) + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) + + this.controller.context.disconnect() + await this.nextFrame + + this.assert.equal( + this.controller.alphaOutletElements.filter((outlet) => outlet.classList.contains("disconnected")).length, + 2 + ) + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 2) + } + + async "test outlet disconnected callback when element is removed"() { + const disconnectedAlpha = this.findElement("#alpha1") + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) + this.assert.notOk( + disconnectedAlpha.classList.contains("disconnected"), + `expected "${disconnectedAlpha.className}" not to contain "disconnected"` + ) + + disconnectedAlpha.parentElement?.removeChild(disconnectedAlpha) + await this.nextFrame + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) + this.assert.ok( + disconnectedAlpha.classList.contains("disconnected"), + `expected "${disconnectedAlpha.className}" to contain "disconnected"` + ) + this.assert.notOk(disconnectedAlpha.isConnected, "element is not present in document") + } + + async "test outlet disconnected callback when element is removed with namespaced outlet"() { + const disconnectedEpsilon = this.findElement("#epsilon1") + + this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 0) + this.assert.notOk( + disconnectedEpsilon.classList.contains("disconnected"), + `expected "${disconnectedEpsilon.className}" not to contain "disconnected"` + ) + + disconnectedEpsilon.parentElement?.removeChild(disconnectedEpsilon) + await this.nextFrame + + this.assert.equal(this.controller.namespacedEpsilonOutletDisconnectedCallCountValue, 1) + this.assert.ok( + disconnectedEpsilon.classList.contains("disconnected"), + `expected "${disconnectedEpsilon.className}" to contain "disconnected"` + ) + this.assert.notOk(disconnectedEpsilon.isConnected, "element is not present in document") + } + + async "test outlet disconnected callback when an outlet present in the document removes the selector attribute"() { + const element = this.findElement("#alpha1") + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) + this.assert.notOk( + element.classList.contains("disconnected"), + `expected "${element.className}" not to contain "disconnected"` + ) + + element.removeAttribute(`id`) + await this.nextFrame + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) + this.assert.ok( + element.classList.contains("disconnected"), + `expected "${element.className}" to contain "disconnected"` + ) + this.assert.ok(element.isConnected, "element is still present in document") + } + + async "test outlet disconnected callback when an outlet present in the document removes the data-controller attribute"() { + const element = this.findElement("#alpha1") + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 0) + this.assert.notOk( + element.classList.contains("disconnected"), + `expected "${element.className}" not to contain "disconnected"` + ) + + element.removeAttribute(`data-controller`) + await this.nextFrame + + this.assert.equal(this.controller.alphaOutletDisconnectedCallCountValue, 1) + this.assert.ok( + element.classList.contains("disconnected"), + `expected "${element.className}" to contain "disconnected"` + ) + this.assert.ok(element.isConnected, "element is still present in document") + } +} diff --git a/src/tests/modules/core/string_helpers_tests.ts b/src/tests/modules/core/string_helpers_tests.ts new file mode 100644 index 00000000..f739ec93 --- /dev/null +++ b/src/tests/modules/core/string_helpers_tests.ts @@ -0,0 +1,55 @@ +import { TestCase } from "../../cases/test_case" +import * as helpers from "../../../core/string_helpers" + +export default class StringHelpersTests extends TestCase { + "test should camelize strings"() { + this.assert.equal(helpers.camelize("underscore_value"), "underscoreValue") + this.assert.equal(helpers.camelize("Underscore_value"), "UnderscoreValue") + this.assert.equal(helpers.camelize("underscore_Value"), "underscore_Value") + this.assert.equal(helpers.camelize("Underscore_Value"), "Underscore_Value") + this.assert.equal(helpers.camelize("multi_underscore_value"), "multiUnderscoreValue") + + this.assert.equal(helpers.camelize("dash-value"), "dashValue") + this.assert.equal(helpers.camelize("Dash-value"), "DashValue") + this.assert.equal(helpers.camelize("dash-Value"), "dash-Value") + this.assert.equal(helpers.camelize("Dash-Value"), "Dash-Value") + this.assert.equal(helpers.camelize("multi-dash-value"), "multiDashValue") + } + + "test should namespace camelize strings"() { + this.assert.equal(helpers.namespaceCamelize("underscore__value"), "underscoreValue") + this.assert.equal(helpers.namespaceCamelize("Underscore__value"), "UnderscoreValue") + this.assert.equal(helpers.namespaceCamelize("underscore__Value"), "underscore_Value") + this.assert.equal(helpers.namespaceCamelize("Underscore__Value"), "Underscore_Value") + this.assert.equal(helpers.namespaceCamelize("multi__underscore__value"), "multiUnderscoreValue") + + this.assert.equal(helpers.namespaceCamelize("dash--value"), "dashValue") + this.assert.equal(helpers.namespaceCamelize("Dash--value"), "DashValue") + this.assert.equal(helpers.namespaceCamelize("dash--Value"), "dash-Value") + this.assert.equal(helpers.namespaceCamelize("Dash--Value"), "Dash-Value") + this.assert.equal(helpers.namespaceCamelize("multi--dash--value"), "multiDashValue") + } + + "test should dasherize strings"() { + this.assert.equal(helpers.dasherize("camelizedValue"), "camelized-value") + this.assert.equal(helpers.dasherize("longCamelizedValue"), "long-camelized-value") + } + + "test should capitalize strings"() { + this.assert.equal(helpers.capitalize("lowercase"), "Lowercase") + this.assert.equal(helpers.capitalize("Uppercase"), "Uppercase") + } + + "test should tokenize strings"() { + this.assert.deepEqual(helpers.tokenize(""), []) + this.assert.deepEqual(helpers.tokenize("one"), ["one"]) + this.assert.deepEqual(helpers.tokenize("two words"), ["two", "words"]) + this.assert.deepEqual(helpers.tokenize("a_lot of-words with special--chars mixed__in"), [ + "a_lot", + "of-words", + "with", + "special--chars", + "mixed__in", + ]) + } +} diff --git a/src/tests/modules/mutation-observers/selector_observer_tests.ts b/src/tests/modules/mutation-observers/selector_observer_tests.ts new file mode 100644 index 00000000..2e29b71d --- /dev/null +++ b/src/tests/modules/mutation-observers/selector_observer_tests.ts @@ -0,0 +1,133 @@ +import { SelectorObserver, SelectorObserverDelegate } from "../../../mutation-observers/selector_observer" +import { ObserverTestCase } from "../../cases/observer_test_case" + +export default class SelectorObserverTests extends ObserverTestCase implements SelectorObserverDelegate { + attributeName = "data-test" + selector = "div[data-test~=two]" + details = { some: "details" } + + fixtureHTML = ` +
+
+
+ + +
+ ` + observer = new SelectorObserver(this.fixtureElement, this.selector, this, this.details) + + async "test should match when observer starts"() { + this.assert.deepEqual(this.calls, [ + ["selectorMatched", this.element, this.selector, this.details], + ["selectorMatched", this.div2, this.selector, this.details], + ]) + } + + async "test should match when element gets appended"() { + const element1 = document.createElement("div") + const element2 = document.createElement("div") + + element1.dataset.test = "one two" + element2.dataset.test = "three four" + + this.element.appendChild(element1) + this.element.appendChild(element2) + + await this.nextFrame + + this.assert.deepEqual(this.calls, [ + ["selectorMatched", this.element, this.selector, this.details], + ["selectorMatched", this.div2, this.selector, this.details], + ["selectorMatched", element1, this.selector, this.details], + ]) + } + + async "test should not match/unmatch when the attribute gets updated and matching selector persists"() { + this.element.setAttribute(this.attributeName, "two three") + + await this.nextFrame + + this.assert.deepEqual(this.testCalls, []) + } + + async "test should match when attribute gets updated and start to matche selector"() { + this.div1.setAttribute(this.attributeName, "updated two") + + await this.nextFrame + + this.assert.deepEqual(this.testCalls, [["selectorMatched", this.div1, this.selector, this.details]]) + } + + async "test should unmatch when attribute gets updated but matching attribute value gets removed"() { + this.div2.setAttribute(this.attributeName, "updated") + + await this.nextFrame + + this.assert.deepEqual(this.testCalls, [["selectorUnmatched", this.div2, this.selector, this.details]]) + } + + async "test should unmatch when attribute gets removed"() { + this.element.removeAttribute(this.attributeName) + this.div2.removeAttribute(this.attributeName) + + await this.nextFrame + + this.assert.deepEqual(this.testCalls, [ + ["selectorUnmatched", this.element, this.selector, this.details], + ["selectorUnmatched", this.div2, this.selector, this.details], + ]) + } + + async "test should unmatch when element gets removed"() { + const element = this.element + const div1 = this.div1 + const div2 = this.div2 + + element.remove() + div1.remove() + div2.remove() + + await this.nextFrame + + this.assert.deepEqual(this.testCalls, [ + ["selectorUnmatched", element, this.selector, this.details], + ["selectorUnmatched", div2, this.selector, this.details], + ]) + } + + async "test should not match/unmatch when observer is paused"() { + this.observer.pause(() => { + this.div2.remove() + + const element = document.createElement("div") + element.dataset.test = "one two" + this.element.appendChild(element) + }) + + await this.nextFrame + + this.assert.deepEqual(this.testCalls, []) + } + + get element(): Element { + return this.findElement("#container") + } + + get div1(): Element { + return this.findElement("#div1") + } + + get div2(): Element { + return this.findElement("#div2") + } + + // Selector observer delegate + + selectorMatched(element: Element, selector: string, details: object) { + this.recordCall("selectorMatched", element, selector, details) + } + + selectorUnmatched(element: Element, selector: string, details: object) { + this.recordCall("selectorUnmatched", element, selector, details) + } +}