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: Add observers for controller element attributes #624

Merged
merged 6 commits into from
Jan 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
130 changes: 108 additions & 22 deletions src/core/outlet_observer.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,130 @@
import { Multimap } from "../multimap"
import { AttributeObserver, AttributeObserverDelegate } from "../mutation-observers"
import { SelectorObserver, SelectorObserverDelegate } from "../mutation-observers"
import { Context } from "./context"
import { Controller } from "./controller"

import { readInheritableStaticArrayValues } from "./inheritable_statics"

type SelectorObserverDetails = { outletName: string }
type OutletObserverDetails = { 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 {
export class OutletObserver implements AttributeObserverDelegate, SelectorObserverDelegate {
started: boolean
readonly context: Context
readonly delegate: OutletObserverDelegate
readonly outletsByName: Multimap<string, Controller>
readonly outletElementsByName: Multimap<string, Element>
private selectorObserverMap: Map<string, SelectorObserver>
private attributeObserverMap: Map<string, AttributeObserver>

constructor(context: Context, delegate: OutletObserverDelegate) {
this.started = false
this.context = context
this.delegate = delegate
this.outletsByName = new Multimap()
this.outletElementsByName = new Multimap()
this.selectorObserverMap = new Map()
this.attributeObserverMap = new Map()
}

start() {
if (this.selectorObserverMap.size === 0) {
if (!this.started) {
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.setupSelectorObserverForOutlet(outletName)
this.setupAttributeObserverForOutlet(outletName)
})

this.selectorObserverMap.forEach((observer) => observer.start())
this.started = true
this.dependentContexts.forEach((context) => context.refresh())
}
}

this.dependentContexts.forEach((context) => context.refresh())
refresh() {
this.selectorObserverMap.forEach((observer) => observer.refresh())
this.attributeObserverMap.forEach((observer) => observer.refresh())
}

stop() {
if (this.selectorObserverMap.size > 0) {
if (this.started) {
this.started = false
this.disconnectAllOutlets()
this.stopSelectorObservers()
this.stopAttributeObservers()
}
}

stopSelectorObservers() {
if (this.selectorObserverMap.size > 0) {
this.selectorObserverMap.forEach((observer) => observer.stop())
this.selectorObserverMap.clear()
}
}

refresh() {
this.selectorObserverMap.forEach((observer) => observer.refresh())
stopAttributeObservers() {
if (this.attributeObserverMap.size > 0) {
this.attributeObserverMap.forEach((observer) => observer.stop())
this.attributeObserverMap.clear()
}
}

// Selector observer delegate

selectorMatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) {
selectorMatched(element: Element, _selector: string, { outletName }: OutletObserverDetails) {
const outlet = this.getOutlet(element, outletName)

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

selectorUnmatched(element: Element, _selector: string, { outletName }: SelectorObserverDetails) {
selectorUnmatched(element: Element, _selector: string, { outletName }: OutletObserverDetails) {
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}]`)
)
selectorMatchElement(element: Element, { outletName }: OutletObserverDetails) {
const selector = this.selector(outletName)
const hasOutlet = this.hasOutlet(element, outletName)
const hasOutletController = element.matches(`[${this.schema.controllerAttribute}~=${outletName}]`)

if (selector) {
return hasOutlet && hasOutletController && element.matches(selector)
} else {
return false
}
}

// Attribute observer delegate

elementMatchedAttribute(_element: Element, attributeName: string) {
const outletName = this.getOutletNameFromOutletAttributeName(attributeName)

if (outletName) {
this.updateSelectorObserverForOutlet(outletName)
}
}

elementAttributeValueChanged(_element: Element, attributeName: string) {
const outletName = this.getOutletNameFromOutletAttributeName(attributeName)

if (outletName) {
this.updateSelectorObserverForOutlet(outletName)
}
}

elementUnmatchedAttribute(_element: Element, attributeName: string) {
const outletName = this.getOutletNameFromOutletAttributeName(attributeName)

if (outletName) {
this.updateSelectorObserverForOutlet(outletName)
}
}

// Outlet management
Expand Down Expand Up @@ -111,12 +157,48 @@ export class OutletObserver implements SelectorObserverDelegate {
}
}

// Observer management

private updateSelectorObserverForOutlet(outletName: string) {
const observer = this.selectorObserverMap.get(outletName)

if (observer) {
observer.selector = this.selector(outletName)
}
}

private setupSelectorObserverForOutlet(outletName: string) {
const selector = this.selector(outletName)
const selectorObserver = new SelectorObserver(document.body, selector!, this, { outletName })

this.selectorObserverMap.set(outletName, selectorObserver)

selectorObserver.start()
}

private setupAttributeObserverForOutlet(outletName: string) {
const attributeName = this.attributeNameForOutletName(outletName)
const attributeObserver = new AttributeObserver(this.scope.element, attributeName, this)

this.attributeObserverMap.set(outletName, attributeObserver)

attributeObserver.start()
}

// Private

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

private attributeNameForOutletName(outletName: string) {
return this.scope.schema.outletAttributeForScope(this.identifier, outletName)
}

private getOutletNameFromOutletAttributeName(attributeName: string) {
return this.outletDefinitions.find((outletName) => this.attributeNameForOutletName(outletName) === attributeName)
}

private get outletDependencies() {
const dependencies = new Multimap<string, string>()

Expand Down Expand Up @@ -159,6 +241,10 @@ export class OutletObserver implements SelectorObserverDelegate {
return this.context.scope
}

private get schema() {
return this.context.schema
}

private get identifier() {
return this.context.identifier
}
Expand Down
95 changes: 65 additions & 30 deletions src/mutation-observers/selector_observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ export interface SelectorObserverDelegate {
}

export class SelectorObserver implements ElementObserverDelegate {
private selector: string
private elementObserver: ElementObserver
private delegate: SelectorObserverDelegate
private matchesByElement: Multimap<string, Element>
private details: object

constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object = {}) {
this.selector = selector
private readonly elementObserver: ElementObserver
private readonly delegate: SelectorObserverDelegate
private readonly matchesByElement: Multimap<string, Element>
private readonly details: object
_selector: string | null

constructor(element: Element, selector: string, delegate: SelectorObserverDelegate, details: object) {
this._selector = selector
this.details = details
this.elementObserver = new ElementObserver(element, this)
this.delegate = delegate
Expand All @@ -26,6 +26,15 @@ export class SelectorObserver implements ElementObserverDelegate {
return this.elementObserver.started
}

get selector() {
return this._selector
}

set selector(selector: string | null) {
this._selector = selector
this.refresh()
}

start() {
this.elementObserver.start()
}
Expand All @@ -49,47 +58,73 @@ export class SelectorObserver implements ElementObserverDelegate {
// Element observer delegate

matchElement(element: Element): boolean {
const matches = element.matches(this.selector)
const { selector } = this

if (this.delegate.selectorMatchElement) {
return matches && this.delegate.selectorMatchElement(element, this.details)
}
if (selector) {
const matches = element.matches(selector)

if (this.delegate.selectorMatchElement) {
return matches && this.delegate.selectorMatchElement(element, this.details)
}

return matches
return matches
} else {
return false
}
}

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)
const { selector } = this

if (selector) {
const match = this.matchElement(tree) ? [tree] : []
const matches = Array.from(tree.querySelectorAll(selector)).filter((match) => this.matchElement(match))
return match.concat(matches)
} else {
return []
}
}

elementMatched(element: Element) {
this.selectorMatched(element)
const { selector } = this

if (selector) {
this.selectorMatched(element, selector)
}
}

elementUnmatched(element: Element) {
this.selectorUnmatched(element)
const selectors = this.matchesByElement.getKeysForValue(element)

for (const selector of selectors) {
this.selectorUnmatched(element, selector)
}
}

elementAttributeChanged(element: Element, _attributeName: string) {
const matches = this.matchElement(element)
const matchedBefore = this.matchesByElement.has(this.selector, element)
const { selector } = this

if (!matches && matchedBefore) {
this.selectorUnmatched(element)
if (selector) {
const matches = this.matchElement(element)
const matchedBefore = this.matchesByElement.has(selector, element)

if (matches && !matchedBefore) {
this.selectorMatched(element, selector)
} else if (!matches && matchedBefore) {
this.selectorUnmatched(element, selector)
}
}
}

private selectorMatched(element: Element) {
if (this.delegate.selectorMatched) {
this.delegate.selectorMatched(element, this.selector, this.details)
this.matchesByElement.add(this.selector, element)
}
// Selector management

private selectorMatched(element: Element, selector: string) {
this.delegate.selectorMatched(element, selector, this.details)
this.matchesByElement.add(selector, element)
}

private selectorUnmatched(element: Element) {
this.delegate.selectorUnmatched(element, this.selector, this.details)
this.matchesByElement.delete(this.selector, element)
private selectorUnmatched(element: Element, selector: string) {
this.delegate.selectorUnmatched(element, selector, this.details)
this.matchesByElement.delete(selector, element)
}
}
28 changes: 28 additions & 0 deletions src/tests/cases/dom_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,34 @@ export class DOMTestCase extends TestCase {
return event
}

async setAttribute(selectorOrElement: string | Element, name: string, value: string) {
const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement

element.setAttribute(name, value)
await this.nextFrame
}

async removeAttribute(selectorOrElement: string | Element, name: string) {
const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement

element.removeAttribute(name)
await this.nextFrame
}

async appendChild<T extends Node>(selectorOrElement: T | string, child: T) {
const parent = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement

parent.appendChild(child)
await this.nextFrame
}

async remove(selectorOrElement: Element | string) {
const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement

element.remove()
await this.nextFrame
}

findElement(selector: string) {
const element = this.fixtureElement.querySelector(selector)
if (element) {
Expand Down