Skip to content

Commit 13a117a

Browse files
authored
Merge pull request #73 from github/support-shadowdom
Support shadowdom
2 parents 7a07528 + d34bb8a commit 13a117a

6 files changed

Lines changed: 127 additions & 19 deletions

File tree

docs/_guide/actions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ class HoverCard extends HTMLElement {
138138
}
139139
```
140140

141+
### Targets and "ShadowRoots"
142+
143+
Custom elements can create encapsulated DOM trees known as "Shadow" DOM. Catalyst actions support Shadow DOM by traversing the `shadowRoot`, if present, and also automatically watching shadowRoots for changes; auto-binding new elements as they are added.
144+
141145
### What about without Decorators?
142146

143147
If you're using decorators, then the `@controller` decorator automatically handles binding of actions to a Controller.

docs/_guide/targets.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ To clarify the difference between `@target` and `@targets` here is a handy table
121121
| `@target` | `querySelector` | `data-target="*"` | `Element` |
122122
| `@targets` | `querySelectorAll` | `data-targets="*"` | `Array<Element>` |
123123

124+
### Targets and "ShadowRoots"
125+
126+
Custom elements can create encapsulated DOM trees known as "Shadow" DOM. Catalyst targets support Shadow DOM by traversing the `shadowRoot` first, if present.
127+
128+
Important to note here is that nodes from the `shadowRoot` get returned _first_. So `@targets` will return an array of nodes, where shadowRoot nodes are at the start of the Array, and `@target` will return a ShadowRoot target if it exists, otherwise it will fall back to traversing the elements direct children.
129+
124130
### What about without Decorators?
125131

126132
If you're using decorators, then the `@target` and `@targets` decorators will turn the decorated properties into getters.

src/bind.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ const controllers = new Set<string>()
66
*/
77
export function bind(controller: HTMLElement): void {
88
controllers.add(controller.tagName.toLowerCase())
9+
if (controller.shadowRoot) {
10+
bindElements(controller.shadowRoot)
11+
listenForBind(controller.shadowRoot)
12+
}
913
bindElements(controller)
1014
}
1115

@@ -48,12 +52,12 @@ interface Subscription {
4852
unsubscribe(): void
4953
}
5054

51-
function bindElements(root: Element) {
55+
function bindElements(root: Element | ShadowRoot) {
5256
for (const el of root.querySelectorAll('[data-action]')) {
5357
bindActions(el)
5458
}
5559
// Also bind the controller to itself
56-
if (root.hasAttribute('data-action')) {
60+
if (root instanceof Element && root.hasAttribute('data-action')) {
5761
bindActions(root)
5862
}
5963
}
@@ -63,10 +67,18 @@ function handleEvent(event: Event) {
6367
const el = event.currentTarget as Element
6468
for (const binding of bindings(el)) {
6569
if (event.type === binding.type && controllers.has(binding.tag)) {
66-
const controller = el.closest(binding.tag) as Element & Record<string, (ev: Event) => unknown>
70+
type EventDispatcher = Element & Record<string, (ev: Event) => unknown>
71+
const controller = el.closest(binding.tag) as EventDispatcher
6772
if (controller && typeof controller[binding.method] === 'function') {
6873
controller[binding.method](event)
6974
}
75+
const root = el.getRootNode()
76+
if (root instanceof ShadowRoot && root.host.matches(binding.tag)) {
77+
const shadowController = root.host as EventDispatcher
78+
if (typeof shadowController[binding.method] === 'function') {
79+
shadowController[binding.method](event)
80+
}
81+
}
7082
}
7183
}
7284
}

src/findtarget.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
2-
* findTarget will run `querySelectorAll` against the given controller,
3-
* returning any the first child that:
2+
* findTarget will run `querySelectorAll` against the given controller, plus
3+
* its shadowRoot, returning any the first child that:
44
*
55
* - Matches the selector of `[data-target~="tag.name"]` where tag is the
66
* tagName of the given HTMLElement, and `name` is the given `name` argument.
@@ -12,6 +12,11 @@
1212
*/
1313
export function findTarget(controller: HTMLElement, name: string): Element | undefined {
1414
const tag = controller.tagName.toLowerCase()
15+
if (controller.shadowRoot) {
16+
for (const el of controller.shadowRoot.querySelectorAll(`[data-target~="${tag}.${name}"]`)) {
17+
if (!el.closest(tag)) return el
18+
}
19+
}
1520
for (const el of controller.querySelectorAll(`[data-target~="${tag}.${name}"]`)) {
1621
if (el.closest(tag) === controller) return el
1722
}
@@ -20,6 +25,11 @@ export function findTarget(controller: HTMLElement, name: string): Element | und
2025
export function findTargets(controller: HTMLElement, name: string): Element[] {
2126
const tag = controller.tagName.toLowerCase()
2227
const targets = []
28+
if (controller.shadowRoot) {
29+
for (const el of controller.shadowRoot.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) {
30+
if (!el.closest(tag)) targets.push(el)
31+
}
32+
}
2333
for (const el of controller.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) {
2434
if (el.closest(tag) === controller) targets.push(el)
2535
}

test/bind.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,45 @@ describe('bind', () => {
138138
expect(instance.foo).to.have.been.called.exactly(2)
139139
})
140140

141+
it('can bind elements within the shadowDOM', () => {
142+
const instance = document.createElement('bind-test-element')
143+
chai.spy.on(instance, 'foo')
144+
instance.attachShadow({mode: 'open'})
145+
const el1 = document.createElement('div')
146+
const el2 = document.createElement('div')
147+
el1.setAttribute('data-action', 'click:bind-test-element#foo')
148+
el2.setAttribute('data-action', 'submit:bind-test-element#foo')
149+
instance.shadowRoot.append(el1, el2)
150+
bind(instance)
151+
expect(instance.foo).to.have.not.been.called()
152+
el1.click()
153+
expect(instance.foo).to.have.been.called.exactly(1)
154+
el2.dispatchEvent(new CustomEvent('submit'))
155+
expect(instance.foo).to.have.been.called.exactly(2)
156+
})
157+
158+
it('binds elements added to shadowDOM', async () => {
159+
const instance = document.createElement('bind-test-element')
160+
chai.spy.on(instance, 'foo')
161+
instance.attachShadow({mode: 'open'})
162+
const el1 = document.createElement('div')
163+
const el2 = document.createElement('div')
164+
el1.setAttribute('data-action', 'click:bind-test-element#foo')
165+
el2.setAttribute('data-action', 'submit:bind-test-element#foo')
166+
bind(instance)
167+
instance.shadowRoot.append(el1)
168+
instance.shadowRoot.append(el2)
169+
// We need to wait for a couple of frames after injecting the HTML into to
170+
// controller so that the actions have been bound to the controller.
171+
await waitForNextAnimationFrame()
172+
await waitForNextAnimationFrame()
173+
expect(instance.foo).to.have.not.been.called()
174+
el1.click()
175+
expect(instance.foo).to.have.been.called.exactly(1)
176+
el2.dispatchEvent(new CustomEvent('submit'))
177+
expect(instance.foo).to.have.been.called.exactly(2)
178+
})
179+
141180
describe('listenForBind', () => {
142181
it('re-binds actions that are denoted by HTML that is dynamically injected into the controller', async function () {
143182
const instance = document.createElement('bind-test-element')

test/findtarget.js

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,29 +79,66 @@ describe('findTarget', () => {
7979
expect(foundElement1).to.equal(el)
8080
expect(foundElement2).to.equal(undefined)
8181
})
82+
83+
it('returns targets from the shadowRoot, if available', () => {
84+
const instance = document.createElement('find-target-test-element')
85+
instance.attachShadow({mode: 'open'})
86+
const el = document.createElement('div')
87+
el.setAttribute('data-target', 'find-target-test-element.foobar')
88+
89+
instance.shadowRoot.appendChild(el)
90+
91+
expect(findTarget(instance, 'foobar')).to.equal(el)
92+
})
93+
94+
it('prioritises shadowRoot targets over others', () => {
95+
const instance = document.createElement('find-target-test-element')
96+
instance.attachShadow({mode: 'open'})
97+
const shadowEl = document.createElement('div')
98+
shadowEl.setAttribute('data-target', 'find-target-test-element.foobar')
99+
const lightEl = document.createElement('div')
100+
lightEl.setAttribute('data-target', 'find-target-test-element.foobar')
101+
102+
instance.shadowRoot.appendChild(shadowEl)
103+
instance.appendChild(lightEl)
104+
105+
expect(findTarget(instance, 'foobar')).to.equal(shadowEl)
106+
})
82107
})
83108

84109
describe('findTargets', () => {
85110
it('calls querySelectorAll with the controller name and target name', () => {
86111
const instance = document.createElement('find-target-test-element')
87-
chai.spy.on(instance, 'querySelectorAll', () => [])
88-
findTargets(instance, 'foo')
89-
expect(instance.querySelectorAll).to.have.been.called.once.with.exactly(
90-
'[data-targets~="find-target-test-element.foo"]'
91-
)
112+
const els = [document.createElement('div'), document.createElement('div'), document.createElement('div')]
113+
instance.append(...els)
114+
115+
els[0].setAttribute('data-targets', 'find-target-test-element.foo')
116+
els[1].setAttribute('data-targets', 'find-target-test-element.foo')
117+
118+
expect(findTargets(instance, 'foo')).to.eql([els[0], els[1]])
92119
})
93120

94121
it('returns all elements where closest tag is the controller', () => {
122+
const instance = document.createElement('find-target-test-element')
95123
const els = [document.createElement('div'), document.createElement('div'), document.createElement('div')]
124+
for (const el of els) el.setAttribute('data-targets', 'find-target-test-element.foo')
125+
const nested = document.createElement('find-target-test-element')
126+
127+
nested.append(els[1])
128+
instance.append(els[0], nested, els[2])
129+
130+
expect(findTargets(instance, 'foo')).to.eql([els[0], els[2]])
131+
})
132+
133+
it('returns all elements inside a shadow root', () => {
96134
const instance = document.createElement('find-target-test-element')
97-
chai.spy.on(instance, 'querySelectorAll', () => els)
98-
chai.spy.on(els[0], 'closest', () => instance)
99-
chai.spy.on(els[1], 'closest', () => null)
100-
chai.spy.on(els[2], 'closest', () => instance)
101-
const targets = findTargets(instance, 'foo')
102-
expect(els[0].closest).to.have.been.called.once.with.exactly('find-target-test-element')
103-
expect(els[1].closest).to.have.been.called.once.with.exactly('find-target-test-element')
104-
expect(els[2].closest).to.have.been.called.once.with.exactly('find-target-test-element')
105-
expect(targets).to.deep.equal([els[0], els[2]])
135+
instance.attachShadow({mode: 'open'})
136+
const els = [document.createElement('div'), document.createElement('div'), document.createElement('div')]
137+
for (const el of els) el.setAttribute('data-targets', 'find-target-test-element.foo')
138+
139+
instance.shadowRoot.append(els[1])
140+
instance.append(els[0], els[2])
141+
142+
expect(findTargets(instance, 'foo')).to.eql([els[1], els[0], els[2]])
106143
})
107144
})

0 commit comments

Comments
 (0)