Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/actionable.ts
Original file line number Diff line number Diff line change
@@ -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<CustomElement>()
// 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<string, (ev: Event) => unknown>
const controller = el.closest<EventDispatcher>(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(
<T extends CustomElementClass>(Class: T): T & ControllableClass =>
class extends controllable(Class) {
constructor() {
super()
actionables.add(this)
add(this)
}

[attachShadowCallback](root: ShadowRoot) {
super[attachShadowCallback]?.(root)
add(root)
}
}
)
111 changes: 0 additions & 111 deletions src/bind.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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
* registry, as well as ensuring `bind(this)` is called on `connectedCallback`,
* wrapping the classes `connectedCallback` method if needed.
*/
export function controller(classObject: CustomElementClass): void {
new CatalystDelegate(classObject)
new CatalystDelegate(actionable(classObject))
}
3 changes: 0 additions & 3 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
27 changes: 22 additions & 5 deletions test/bind.ts → test/actionable.ts
Original file line number Diff line number Diff line change
@@ -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`<bind-test data-action="foo:bind-test#foo">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 0 additions & 32 deletions test/controller.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -83,35 +82,4 @@ describe('controller', () => {
</parent-element>
`)
})

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<AttributeTestElement>(html`<attribute-test></attribute-test>`)
instance.foo = 'bar'
instance.attributeChangedCallback()
expect(attrValues).to.eql(['bar', 'bar'])
})

it('initializes attributes as attrs in attributeChangedCallback', async () => {
instance = await fixture<AttributeTestElement>(html`<attribute-test />`)
instance.setAttribute('data-foo', 'bar')
instance.attributeChangedCallback()
expect(attrValues).to.eql(['bar', 'bar'])
})
})
})