Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Introduce Controller Outlets * Observe outlets to trigger outlet callbacks * Add tests for Outlets * Unify signatures for outlet callbacks * Swap out `TokenListObserver` for new `SelectorObserver` The approach using the `SelectorObserver` is much more reliable to fire the outlet connected and disconnected callbacks. It's now matching against the selector provided in the `data-[controller]-[outlet]-outlet` attribute instead of just looking for elements which have a `data-controller` attribute to appear. Previously, when a `data-controller` appeared we checked it's attribute value to see if it was relevant as an outlet for the current controller. Using the `SelectorObserver` we now solve an edge case where the user would dynamically add/remove an attribute to the outlet element which would then make that element relevant or not relevant anymore as an outlet for the current controller. We need to know that so that we can reliably fire the outlet callbacks. Previously I wouldn't have fired the callbacks. For every outlet we define we now create a separate instance of the `SelectorObserver` which also handles that we just match the relevant outlets by adding the `data-controller~=[outletName]` selector to the CSS selector we lookup. * Use `outletName` over `name` * Add `namespaceCamelize` helper and add tests for all string helpers * Support Outlets for namespaced controllers * Invoke outlet callbacks for instances that get connected after the current instance gets connected There was an edge case where outlets wouldn't fire the outlet callbacks if they appeared later in the DOM but the current instance had a "dependency" on them. To solve this the OutletObserver now "notifies" it's dependents that it conencted so they can refire the matching elements via the `refresh()` function of the ElementObserver. * move `bodyScope` to getter and use `documentElement` instead of `body`
- Loading branch information
Showing
14 changed files
with
1,052 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string, Controller> | ||
readonly outletElementsByName: Multimap<string, Element> | ||
private selectorObserverMap: Map<string, SelectorObserver> | ||
|
||
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<string, string>() | ||
|
||
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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) { | ||
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) | ||
}, | ||
}, | ||
} | ||
} |
Oops, something went wrong.