Skip to content
Closed
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
54 changes: 31 additions & 23 deletions src/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,12 @@ export function bind(controller: HTMLElement): void {
const actionAttributeMatcher = `[data-action*=":${tag}#"]`

for (const el of controller.querySelectorAll(actionAttributeMatcher)) {
// Ignore nested elements
if (el.closest(tag) !== controller) continue
bindActionsToController(el, controller)
bindActions(el)
}

// Also bind the controller to itself
if (controller.matches(actionAttributeMatcher)) {
bindActionsToController(controller, controller)
bindActions(controller)
}
}

Expand All @@ -35,26 +33,30 @@ function* getActions(el: Element): Generator<[string, string, string]> {
}
}

function bindActionToController(controller: HTMLElement, el: Element, methodName: string, eventName: string) {
// Check the `method` is present on the prototype
const methodDescriptor =
Object.getOwnPropertyDescriptor(controller, methodName) ||
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), methodName)
if (methodDescriptor && typeof methodDescriptor.value == 'function') {
el.addEventListener(eventName, (event: Event) => {
methodDescriptor.value.call(controller, event)
})
const registeredEvents: WeakMap<Element, Set<string>> = new WeakMap()

function handleEvent(event: Event) {
const el = event.currentTarget
if (!(el instanceof Element)) return
if (!el.hasAttribute('data-action')) return
for (const [eventName, tagName, methodName] of getActions(el)) {
if (eventName !== event.type) continue
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const controller = el.closest(tagName) as any
if (controller && methodName in controller && typeof controller[methodName] === 'function') {
controller[methodName](event)
}
}
}

// Bind the data-action attribute of a single element to the controller
function bindActionsToController(el: Element, controller: Element | null = null) {
for (const [eventName, tagName, methodName] of getActions(el)) {
function bindActions(el: Element) {
for (const [eventName, tagName] of getActions(el)) {
if (!bound.has(tagName)) continue
if (!controller) controller = el.closest(tagName)
if (!(controller instanceof HTMLElement)) continue
if (controller.tagName.toLowerCase() !== tagName.toLowerCase()) continue
bindActionToController(controller, el, methodName, eventName)
const bindings = registeredEvents.get(el) || new Set()
if (bindings.has(eventName)) continue
el.addEventListener(eventName, handleEvent)
bindings.add(eventName)
registeredEvents.set(el, bindings)
}
}

Expand All @@ -81,12 +83,18 @@ export function listenForBind(el: Node = document, batchSize = 30): Subscription
if (!(node instanceof Element)) continue
queue.add(node)
}
} else if (
mutation.type === 'attributes' &&
mutation.attributeName === 'data-action' &&
mutation.target instanceof Element
) {
queue.add(mutation.target)
}
}
if (queue.size) processQueue(queue, batchSize)
})

observer.observe(el, {childList: true, subtree: true})
observer.observe(el, {attributes: true, attributeFilter: ['data-action'], childList: true, subtree: true})

return {
get closed() {
Expand All @@ -106,12 +114,12 @@ async function processQueue(queue: Set<Element>, batchSize: number) {
let counter = 0
for (const el of queue) {
if (el.hasAttribute('data-action')) {
bindActionsToController(el)
bindActions(el)
queue.delete(el)
if ((counter += 1) % batchSize === 0) await animationFrame()
}
for (const child of el.querySelectorAll('[data-action]')) {
bindActionsToController(child)
bindActions(child)
if ((counter += 1) % batchSize === 0) await animationFrame()
}
}
Expand Down
137 changes: 72 additions & 65 deletions test/bind.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,129 +31,117 @@ describe('bind', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el = document.createElement('div')
instance.querySelectorAll = () => [el]
el.closest = () => instance
el.getAttribute = () => 'click:bind-test-element#foo'
chai.spy.on(el, 'addEventListener')
el.setAttribute('data-action', 'click:bind-test-element#foo')
instance.appendChild(el)
bind(instance)
expect(el.addEventListener).to.have.been.called.once.with('click')
const {calls} = el.addEventListener.__spy
const fn = calls[0][1]
expect(instance.foo).to.have.not.been.called()
fn()
el.click()
expect(instance.foo).to.have.been.called(1)
})

it('allows for the presence of `:` in an event name', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el = document.createElement('div')
instance.querySelectorAll = () => [el]
el.closest = () => instance
el.getAttribute = () => 'custom:event:bind-test-element#foo'
chai.spy.on(el, 'addEventListener')
el.setAttribute('data-action', 'custom:event:bind-test-element#foo')
instance.appendChild(el)
bind(instance)
expect(el.addEventListener).to.have.been.called.once.with('custom:event')
const {calls} = el.addEventListener.__spy
const fn = calls[0][1]
expect(instance.foo).to.have.not.been.called()
fn()
el.dispatchEvent(new CustomEvent('custom:event'))
expect(instance.foo).to.have.been.called(1)
})

it('binds events on the controller to itself', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
instance.matches = () => true
instance.getAttribute = () => 'click:bind-test-element#foo'
instance.addEventListener = () => true
instance.querySelectorAll = () => []
chai.spy.on(instance, 'addEventListener')
instance.setAttribute('data-action', 'click:bind-test-element#foo')
bind(instance)
expect(instance.addEventListener).to.have.been.called.once.with('click')
const {calls} = instance.addEventListener.__spy
const fn = calls[0][1]
expect(instance.foo).to.have.not.been.called()
fn()
instance.click()
expect(instance.foo).to.have.been.called(1)
})

it('does not bind elements whose closest selector is not this controller', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el = document.createElement('div')
instance.querySelectorAll = () => [el]
el.closest = () => null
el.getAttribute = () => 'click:bind-test-element#foo'
chai.spy.on(el, 'addEventListener')
el.getAttribute('data-action', 'click:bind-test-element#foo')
const container = document.createElement('div')
container.append(instance, el)
bind(instance)
expect(el.addEventListener).to.have.not.been.called()
el.click()
expect(instance.foo).to.have.not.been.called()
})

it('does not bind elements whose data-action does not match controller tagname', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el = document.createElement('div')
instance.querySelectorAll = () => [el]
el.closest = () => null
el.getAttribute = () => 'click:other-controller#foo'
chai.spy.on(el, 'addEventListener')
el.setAttribute('data-action', 'click:other-controller#foo')
instance.appendChild(el)
bind(instance)
expect(el.addEventListener).to.have.not.been.called()
expect(instance.foo).to.have.not.been.called()
el.click()
expect(instance.foo).to.have.not.been.called()
})

it('does not bind methods that dont exist', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el = document.createElement('div')
instance.querySelectorAll = () => [el]
el.closest = () => instance
el.getAttribute = () => 'click:bind-test-element#frob'
chai.spy.on(el, 'addEventListener')
el.setAttribute('data-action', 'click:bind-test-element#frob')
instance.appendChild(el)
bind(instance)
expect(el.addEventListener).to.have.not.been.called()
el.click()
expect(instance.foo).to.have.not.been.called()
})

it('can bind multiple event types', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el = document.createElement('div')
instance.querySelectorAll = () => [el]
el.closest = () => instance
el.getAttribute = () => 'click:bind-test-element#foo submit:bind-test-element#foo'
chai.spy.on(el, 'addEventListener')
el.setAttribute('data-action', 'click:bind-test-element#foo submit:bind-test-element#foo')
instance.appendChild(el)
bind(instance)
expect(el.addEventListener).to.have.been.called(2)
expect(el.addEventListener).to.be.first.called.with('click')
expect(el.addEventListener).to.be.second.called.with('submit')
const {calls} = el.addEventListener.__spy
expect(instance.foo).to.have.not.been.called()
calls[0][1]('a')
expect(instance.foo).to.have.been.called.once.with('a')
calls[1][1]('b')
expect(instance.foo).to.have.been.called.twice.second.with('b')
el.dispatchEvent(new CustomEvent('click'))
expect(instance.foo).to.have.been.called.exactly(1)
el.dispatchEvent(new CustomEvent('submit'))
expect(instance.foo).to.have.been.called.exactly(2)
const calls = instance.foo.__spy.calls
expect(calls).to.have.nested.property('[0][0].type', 'click')
expect(calls).to.have.nested.property('[1][0].type', 'submit')
})

it('can bind multiple elements to the same event', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el1 = document.createElement('div')
const el2 = document.createElement('div')
instance.querySelectorAll = () => [el1, el2]
el1.closest = () => instance
el2.closest = () => instance
el1.getAttribute = () => 'click:bind-test-element#foo'
el2.getAttribute = () => 'submit:bind-test-element#foo'
chai.spy.on(el1, 'addEventListener')
chai.spy.on(el2, 'addEventListener')
el1.setAttribute('data-action', 'click:bind-test-element#foo')
el2.setAttribute('data-action', 'submit:bind-test-element#foo')
instance.append(el1, el2)
bind(instance)
expect(instance.foo).to.have.not.been.called()
el1.click()
expect(instance.foo).to.have.been.called.exactly(1)
el2.dispatchEvent(new CustomEvent('submit'))
expect(instance.foo).to.have.been.called.exactly(2)
})

it('will not fire if the binding attribute is removed', () => {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
const el1 = document.createElement('div')
el1.setAttribute('data-action', 'click:bind-test-element#foo')
instance.appendChild(el1)
bind(instance)
expect(el1.addEventListener).to.be.called.once.with('click')
expect(el2.addEventListener).to.be.called.once.with('submit')
expect(instance.foo).to.have.not.been.called()
el1.addEventListener.__spy.calls[0][1]('a')
expect(instance.foo).to.have.been.called.once.with('a')
el2.addEventListener.__spy.calls[0][1]('b')
expect(instance.foo).to.have.been.called.twice.second.with('b')
el1.click()
expect(instance.foo).to.have.been.called.exactly(1)
el1.setAttribute('data-action', 'click:other-element#foo')
el1.click()
expect(instance.foo).to.have.been.called.exactly(1)
})

describe('listenForBind', () => {
Expand Down Expand Up @@ -219,5 +207,24 @@ describe('bind', () => {
button.click()
expect(instance.foo).to.have.been.called.exactly(0)
})

it('will rebind elements if the attribute changes', async function () {
const instance = document.createElement('bind-test-element')
chai.spy.on(instance, 'foo')
root.appendChild(instance)
const button = document.createElement('button')
button.setAttribute('data-action', 'submit:bind-test-element#foo')
instance.appendChild(button)
bind(instance)
listenForBind(root)
await waitForNextAnimationFrame()
button.click()
expect(instance.foo).to.have.been.called.exactly(0)
button.setAttribute('data-action', 'click:bind-test-element#foo')
await waitForNextAnimationFrame()
await waitForNextAnimationFrame()
button.click()
expect(instance.foo).to.have.been.called.exactly(1)
})
})
})