Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Outlets API #576

Merged
merged 11 commits into from Nov 17, 2022
17 changes: 16 additions & 1 deletion src/core/context.ts
Expand Up @@ -8,14 +8,16 @@ 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"

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
Expand All @@ -24,6 +26,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()
Expand All @@ -37,6 +40,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.bindingObserver.start()
this.valueObserver.start()
this.targetObserver.start()
this.outletObserver.start()

try {
this.controller.connect()
Expand All @@ -54,6 +58,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.handleError(error, "disconnecting controller")
}

this.outletObserver.stop()
this.targetObserver.stop()
this.valueObserver.stop()
this.bindingObserver.stop()
Expand Down Expand Up @@ -109,6 +114,16 @@ export class Context implements ErrorHandler, TargetObserverDelegate {
this.invokeControllerMethod(`${name}TargetDisconnected`, element)
}

// Outlet observer delegate

outletConnected(outlet: Controller, element: Element, name: string) {
this.invokeControllerMethod(`${name}OutletConnected`, outlet, element)
}

outletDisconnected(element: Element, name: string) {
this.invokeControllerMethod(`${name}OutletDisconnected`, element)
}

// Private

invokeControllerMethod(methodName: string, ...args: any[]) {
Expand Down
8 changes: 7 additions & 1 deletion src/core/controller.ts
@@ -1,14 +1,16 @@
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<Controller>

export class Controller<ElementType extends Element = Element> {
static blessings = [ ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing ]
static blessings = [ ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing, OutletPropertiesBlessing ]
static targets: string[] = []
static outlets: string[] = []
static values: ValueDefinitionMap = {}

static get shouldLoad() {
Expand Down Expand Up @@ -41,6 +43,10 @@ export class Controller<ElementType extends Element = Element> {
return this.scope.targets
}

get outlets() {
return this.scope.outlets
}

get classes() {
return this.scope.classes
}
Expand Down
126 changes: 126 additions & 0 deletions src/core/outlet_observer.ts
@@ -0,0 +1,126 @@
import { Multimap } from "../multimap"
import { Token, TokenListObserver, TokenListObserverDelegate } from "../mutation-observers"
import { Context } from "./context"
import { Controller } from "./controller"

import { readInheritableStaticArrayValues } from "./inheritable_statics"

export interface OutletObserverDelegate {
outletConnected(outlet: Controller, element: Element, name: string): void
outletDisconnected(element: Element, name: string): void
marcoroth marked this conversation as resolved.
Show resolved Hide resolved
}

export class OutletObserver implements TokenListObserverDelegate {
readonly context: Context
readonly delegate: OutletObserverDelegate
readonly outletsByName: Multimap<string, Element>
private tokenListObserver?: TokenListObserver

constructor(context: Context, delegate: OutletObserverDelegate) {
this.context = context
this.delegate = delegate
this.outletsByName = new Multimap()
}

start() {
if (!this.tokenListObserver) {
this.tokenListObserver = new TokenListObserver(document.body, this.attributeName, this)
this.tokenListObserver.start()
this.invokeConnectCallbacksForAlreadyConnectedOutlets()
}
}

stop() {
if (this.tokenListObserver) {
this.disconnectAllOutlets()
this.tokenListObserver.stop()
delete this.tokenListObserver
}
}

// Token list observer delegate

tokenMatched({ element, content: name }: Token) {
if (this.outletDefinitions.includes(name) && this.matches(element, name)) {
const outlet = this.getOutlet(element, name)

if (outlet) {
this.connectOutlet(outlet, element, name)
}
}
}

tokenUnmatched({ element, content: name }: Token) {
if (this.outletDefinitions.includes(name) && this.matches(element, name)) {
this.disconnectOutlet(element, name)
}
}

// Outlet management

connectOutlet(outlet: Controller, element: Element, name: string) {
if (!this.outletsByName.has(name, element)) {
this.outletsByName.add(name, element)
this.tokenListObserver?.pause(() => this.delegate.outletConnected(outlet, element, name))
}
}

disconnectOutlet(element: Element, name: string) {
if (this.outletsByName.has(name, element)) {
this.outletsByName.delete(name, element)
this.tokenListObserver?.pause(() => this.delegate.outletDisconnected(element, name))
}
}

disconnectAllOutlets() {
for (const name of this.outletsByName.keys) {
for (const element of this.outletsByName.getValuesForKey(name)) {
this.disconnectOutlet(element, name)
}
}
}

// Private

private getOutlet(element: Element, name: string) {
return this.context.application.getControllerForElementAndIdentifier(element, name)
}

private invokeConnectCallbacksForAlreadyConnectedOutlets() {
for (const name of this.outletDefinitions) {
const elements = this.context.scope.outlets.findAll(name)

elements.forEach(element => {
const outlet = this.getOutlet(element, name)

if (outlet) {
this.connectOutlet(outlet, element, name)
}
})
}
}

private matches(element: Element, outletName: string) {
const selector = this.selector(outletName)

if (!selector) return false

return element.matches(selector)
}

private get outletDefinitions() {
return readInheritableStaticArrayValues(this.context.controller.constructor as any, "outlets")
}

private get attributeName() {
return this.scope.schema.controllerAttribute
}

private selector(outletName: string) {
return this.scope.outlets.getSelectorForOutletName(outletName)
}

private get scope() {
return this.context.scope
}
}
74 changes: 74 additions & 0 deletions src/core/outlet_properties.ts
@@ -0,0 +1,74 @@
import { Constructor } from "./constructor"
import { Controller } from "./controller"
import { readInheritableStaticArrayValues } from "./inheritable_statics"
import { capitalize } from "./string_helpers"

export function OutletPropertiesBlessing<T>(constructor: Constructor<T>) {
const outlets = readInheritableStaticArrayValues(constructor, "outlets")
return outlets.reduce((properties: any, outletDefinition: any) => {
return Object.assign(properties, propertiesForOutletDefinition(outletDefinition))
}, {} as PropertyDescriptorMap)
}

function propertiesForOutletDefinition(name: string) {
return {
[`${name}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`)
}
},

[`${name}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 []
}
},

[`${name}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`)
}
}
},

[`${name}OutletElements`]: {
get(this: Controller) {
return this.outlets.findAll(name)
}
},

[`has${capitalize(name)}Outlet`]: {
get(this: Controller) {
return this.outlets.has(name)
}
}
}
}
71 changes: 71 additions & 0 deletions 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)
}
}
4 changes: 3 additions & 1 deletion src/core/schema.ts
Expand Up @@ -3,11 +3,13 @@ export interface Schema {
actionAttribute: string
targetAttribute: string
targetAttributeForScope(identifier: string): string
outletAttributeForScope(identifier: string, outlet: string): string
}

export const defaultSchema: Schema = {
controllerAttribute: "data-controller",
actionAttribute: "data-action",
targetAttribute: "data-target",
targetAttributeForScope: identifier => `data-${identifier}-target`
targetAttributeForScope: identifier => `data-${identifier}-target`,
outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`
}