diff --git a/src/actionable.ts b/src/actionable.ts new file mode 100644 index 00000000..a797c14a --- /dev/null +++ b/src/actionable.ts @@ -0,0 +1,58 @@ +import type {CustomElementClass, CustomElement} from './custom-element.js' +import type {ControllableClass} from './controllable.js' +import {register, add, tags} from './tag-observer.js' +import {controllable, attachShadowCallback} from './controllable.js' +import {createAbility} from './ability.js' + +const parseActionAttribute = (tag: string): [tagName: string, event: string, method: string] => { + const eventSep = tag.lastIndexOf(':') + const methodSep = Math.max(0, tag.lastIndexOf('#')) || tag.length + return [tag.slice(eventSep + 1, methodSep), tag.slice(0, eventSep), tag.slice(methodSep + 1) || 'handleEvent'] +} +register( + 'data-action', + parseActionAttribute, + (el: Element, controller: Element | ShadowRoot, tag: string, event: string) => { + el.addEventListener(event, handleEvent) + } +) + +const actionables = new WeakSet() +// Bind a single function to all events to avoid anonymous closure performance penalty. +function handleEvent(event: Event) { + const el = event.currentTarget as Element + for (const [tag, type, method] of tags(el, 'data-action', parseActionAttribute)) { + if (event.type === type) { + type EventDispatcher = CustomElement & Record unknown> + const controller = el.closest(tag)! + if (actionables.has(controller) && typeof controller[method] === 'function') { + controller[method](event) + } + const root = el.getRootNode() + if (root instanceof ShadowRoot) { + const shadowController = root.host as EventDispatcher + if (shadowController.matches(tag) && actionables.has(shadowController)) { + if (typeof shadowController[method] === 'function') { + shadowController[method](event) + } + } + } + } + } +} + +export const actionable = createAbility( + (Class: T): T & ControllableClass => + class extends controllable(Class) { + constructor() { + super() + actionables.add(this) + add(this) + } + + [attachShadowCallback](root: ShadowRoot) { + super[attachShadowCallback]?.(root) + add(root) + } + } +) diff --git a/src/bind.ts b/src/bind.ts deleted file mode 100644 index 6b4dc64b..00000000 --- a/src/bind.ts +++ /dev/null @@ -1,111 +0,0 @@ -const controllers = new WeakSet() - -/* - * Bind `[data-action]` elements from the DOM to their actions. - * - */ -export function bind(controller: HTMLElement): void { - controllers.add(controller) - if (controller.shadowRoot) bindShadow(controller.shadowRoot) - bindElements(controller) - listenForBind(controller.ownerDocument) -} - -export function bindShadow(root: ShadowRoot): void { - bindElements(root) - listenForBind(root) -} - -const observers = new WeakMap() -/** - * Set up observer that will make sure any actions that are dynamically - * injected into `el` will be bound to it's controller. - * - * This returns a Subscription object which you can call `unsubscribe()` on to - * stop further live updates. - */ -export function listenForBind(el: Node = document): Subscription { - if (observers.has(el)) return observers.get(el)! - let closed = false - const observer = new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.type === 'attributes' && mutation.target instanceof Element) { - bindActions(mutation.target) - } else if (mutation.type === 'childList' && mutation.addedNodes.length) { - for (const node of mutation.addedNodes) { - if (node instanceof Element) { - bindElements(node) - } - } - } - } - }) - observer.observe(el, {childList: true, subtree: true, attributeFilter: ['data-action']}) - const subscription = { - get closed() { - return closed - }, - unsubscribe() { - closed = true - observers.delete(el) - observer.disconnect() - } - } - observers.set(el, subscription) - return subscription -} - -interface Subscription { - closed: boolean - unsubscribe(): void -} - -function bindElements(root: Element | ShadowRoot) { - for (const el of root.querySelectorAll('[data-action]')) { - bindActions(el) - } - // Also bind the controller to itself - if (root instanceof Element && root.hasAttribute('data-action')) { - bindActions(root) - } -} - -// Bind a single function to all events to avoid anonymous closure performance penalty. -function handleEvent(event: Event) { - const el = event.currentTarget as Element - for (const binding of bindings(el)) { - if (event.type === binding.type) { - type EventDispatcher = HTMLElement & Record unknown> - const controller = el.closest(binding.tag)! - if (controllers.has(controller) && typeof controller[binding.method] === 'function') { - controller[binding.method](event) - } - const root = el.getRootNode() - if (root instanceof ShadowRoot && controllers.has(root.host) && root.host.matches(binding.tag)) { - const shadowController = root.host as EventDispatcher - if (typeof shadowController[binding.method] === 'function') { - shadowController[binding.method](event) - } - } - } - } -} - -type Binding = {type: string; tag: string; method: string} -function* bindings(el: Element): Iterable { - for (const action of (el.getAttribute('data-action') || '').trim().split(/\s+/)) { - const eventSep = action.lastIndexOf(':') - const methodSep = Math.max(0, action.lastIndexOf('#')) || action.length - yield { - type: action.slice(0, eventSep), - tag: action.slice(eventSep + 1, methodSep), - method: action.slice(methodSep + 1) || 'handleEvent' - } || 'handleEvent' - } -} - -function bindActions(el: Element) { - for (const binding of bindings(el)) { - el.addEventListener(binding.type, handleEvent) - } -} diff --git a/src/controller.ts b/src/controller.ts index 13365c8a..53773333 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -1,5 +1,6 @@ import {CatalystDelegate} from './core.js' import type {CustomElementClass} from './custom-element.js' +import {actionable} from './actionable.js' /** * Controller is a decorator to be used over a class that extends HTMLElement. * It will automatically `register()` the component in the customElement @@ -7,5 +8,5 @@ import type {CustomElementClass} from './custom-element.js' * wrapping the classes `connectedCallback` method if needed. */ export function controller(classObject: CustomElementClass): void { - new CatalystDelegate(classObject) + new CatalystDelegate(actionable(classObject)) } diff --git a/src/core.ts b/src/core.ts index 01e07f9a..af33638c 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,5 +1,4 @@ import {register} from './register.js' -import {bind, bindShadow} from './bind.js' import {defineObservedAttributes, initializeAttrs} from './attr.js' import type {CustomElementClass} from './custom-element.js' @@ -53,9 +52,7 @@ export class CatalystDelegate { instance.toggleAttribute('data-catalyst', true) customElements.upgrade(instance) initializeAttrs(instance) - bind(instance) connectedCallback?.call(instance) - if (instance.shadowRoot) bindShadow(instance.shadowRoot) } disconnectedCallback(element: HTMLElement, disconnectedCallback: () => void) { diff --git a/src/index.ts b/src/index.ts index 0f100a8f..bfe63590 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export {bind, listenForBind} from './bind.js' +export {actionable} from './actionable.js' export {register} from './register.js' export {findTarget, findTargets} from './findtarget.js' export {target, targets} from './target.js' diff --git a/test/bind.ts b/test/actionable.ts similarity index 90% rename from test/bind.ts rename to test/actionable.ts index 1047aca0..124619ce 100644 --- a/test/bind.ts +++ b/test/actionable.ts @@ -1,15 +1,15 @@ import {expect, fixture, html} from '@open-wc/testing' import {fake} from 'sinon' -import {controller} from '../src/controller.js' -import {bindShadow} from '../src/bind.js' +import {actionable} from '../src/actionable.js' describe('Actionable', () => { - @controller + @actionable class BindTestElement extends HTMLElement { foo = fake() bar = fake() handleEvent = fake() } + window.customElements.define('bind-test', BindTestElement) let instance: BindTestElement beforeEach(async () => { instance = await fixture(html` @@ -126,7 +126,25 @@ describe('Actionable', () => { el1.setAttribute('data-action', 'click:bind-test#foo') el2.setAttribute('data-action', 'submit:bind-test#foo') const shadowRoot = instance.attachShadow({mode: 'open'}) - bindShadow(shadowRoot) + shadowRoot.append(el1, el2) + + // We need to wait for one microtask after injecting the HTML into to + // controller so that the actions have been bound to the controller. + await Promise.resolve() + + expect(instance.foo).to.have.callCount(0) + el1.click() + expect(instance.foo).to.have.callCount(1) + el2.dispatchEvent(new CustomEvent('submit')) + expect(instance.foo).to.have.callCount(2) + }) + + it('can bind elements within a closed shadowDOM', async () => { + const el1 = document.createElement('div') + const el2 = document.createElement('div') + el1.setAttribute('data-action', 'click:bind-test#foo') + el2.setAttribute('data-action', 'submit:bind-test#foo') + const shadowRoot = instance.attachShadow({mode: 'closed'}) shadowRoot.append(el1, el2) // We need to wait for one microtask after injecting the HTML into to @@ -158,7 +176,6 @@ describe('Actionable', () => { const el1 = document.createElement('div') const el2 = document.createElement('div') const shadowRoot = instance.attachShadow({mode: 'open'}) - bindShadow(shadowRoot) shadowRoot.append(el1, el2) // We need to wait for one microtask after injecting the HTML into to diff --git a/test/controller.ts b/test/controller.ts index 56715d49..9416f66a 100644 --- a/test/controller.ts +++ b/test/controller.ts @@ -1,7 +1,6 @@ import {expect, fixture, html} from '@open-wc/testing' import {replace, fake} from 'sinon' import {controller} from '../src/controller.js' -import {attr} from '../src/attr.js' describe('controller', () => { let instance @@ -83,35 +82,4 @@ describe('controller', () => { `) }) - - describe('attrs', () => { - let attrValues: string[] = [] - @controller - class AttributeTestElement extends HTMLElement { - foo = 'baz' - attributeChangedCallback() { - attrValues.push(this.getAttribute('data-foo')!) - attrValues.push(this.foo) - } - } - attr(AttributeTestElement.prototype, 'foo') - - beforeEach(() => { - attrValues = [] - }) - - it('initializes attrs as attributes in attributeChangedCallback', async () => { - instance = await fixture(html``) - instance.foo = 'bar' - instance.attributeChangedCallback() - expect(attrValues).to.eql(['bar', 'bar']) - }) - - it('initializes attributes as attrs in attributeChangedCallback', async () => { - instance = await fixture(html``) - instance.setAttribute('data-foo', 'bar') - instance.attributeChangedCallback() - expect(attrValues).to.eql(['bar', 'bar']) - }) - }) })