Skip to content

Commit 7b1e56f

Browse files
authored
Merge pull request #74 from github/shadow-dom
Feature: Automatically attach shadow roots with `<template data-shadowroot>`
2 parents 13a117a + 424468e commit 7b1e56f

8 files changed

Lines changed: 215 additions & 5 deletions

File tree

docs/_guide/anti-patterns.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 10
2+
chapter: 11
33
subtitle: Anti Patterns
44
---
55

docs/_guide/conventions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 8
2+
chapter: 9
33
subtitle: Conventions
44
---
55

docs/_guide/lifecycle-hooks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
chapter: 7
2+
chapter: 8
33
subtitle: Observing the life cycle of an element
44
---
55

docs/_guide/patterns.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
---
2-
chapter: 9
2+
chapter: 10
33
subtitle: Patterns
44
---
55

66
An aim of Catalyst is to be as light weight as possible, and so we often avoid including helper functions for otherwise fine code. We also want to keep Catalyst focussed, and so where some helper functions might be reasonable, we recommend judicious use of other small libraries.
77

88
Here are a few common patterns which we've avoided introducing into the Catalyst code base, and instead encourage you to take the example code and run with that:
99

10-
1110
### Debouncing or Throttling events
1211

1312
Often times you'll want to do something computationally intensive (or network intensive) based on a user event. It's worth throttling the amount of times a function can be called for these events, to prevent saturation of the CPU or network. For this we can use the "debounce" or "throttle" patterns. We recommend using the [`@github/mini-throttle`](https://github.com/github/mini-throttle) library for this, which provides throttling decorators for methods:

docs/_guide/rendering.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
chapter: 7
3+
subtitle: Rendering HTML subtrees
4+
---
5+
6+
Sometimes it's necessary to render an HTML subtree as part of a component. This can be especially useful if a component is driving complex UI that is only interactive with JS.
7+
8+
<div class="d-flex border rounded-1 my-3 box-shadow-medium">
9+
<span class="d-flex bg-blue text-white rounded-left-1 p-3">
10+
<svg width="24" viewBox="0 0 14 16" class="octicon octicon-info" aria-hidden="true">
11+
<path
12+
fill-rule="evenodd"
13+
d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"
14+
/>
15+
</svg>
16+
</span>
17+
<div class="p-3">
18+
19+
Remember to _always_ make your JavaScript progressively enhanced, where possible. Using JS to render large portions of the UI, that could be rendered server-side is an anti-pattern; it can be difficult for users to interact with - especially users who disable JS, or when JS fails to load, or those using assistive technologies. Rendering on the client can also impact the [CLS Web Vital](https://web.dev/cls/).
20+
21+
</div>
22+
</div>
23+
24+
By leveraging the native [`ShadowDOM`] feature, Catalyst components can render complex sub-trees, fully encapsulated from the rest of the page.
25+
26+
Catalyst will automatically look for elements that match the `template[data-shadowroot]` selector, within your controller. If it finds one as a direct-child of your controller, it will use that to create a shadowRoot.
27+
28+
Catalyst Controllers will search for a direct child of `template[data-shadowroot]` and load its contents as the `shadowRoot` of the element. [Actions]({{ site.baseurl }}/guide/actions) and [Targets]({{ site.baseurl }}/guide/targets) all work within an elements ShadowRoot.
29+
30+
### Example
31+
32+
```html
33+
<hello-world>
34+
<template data-shadowroot>
35+
<p>
36+
Hello <span data-target="hello-world.nameEl">World</span>
37+
</p>
38+
</template>
39+
</hello-world>
40+
```
41+
```typescript
42+
import { controller, target } from "@github/catalyst"
43+
44+
@controller
45+
class HelloWorldElement extends HTMLElement {
46+
@target nameEl: HTMLElement
47+
get name() {
48+
return this.nameEl.textContent
49+
}
50+
set name(value: string) {
51+
this.nameEl.textContent = value
52+
}
53+
}
54+
```
55+
56+
Providing the `<template data-shadowroot>` element as a direct child of the `hello-world` element tells Catalyst to render the templates contents automatically, and so all `HelloWorldElements` with this template will be rendered with the contents.
57+
58+
<div class="d-flex border rounded-1 my-3 box-shadow-medium">
59+
<span class="d-flex bg-blue text-white rounded-left-1 p-3">
60+
<svg width="24" viewBox="0 0 14 16" class="octicon octicon-info" aria-hidden="true">
61+
<path
62+
fill-rule="evenodd"
63+
d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"
64+
/>
65+
</svg>
66+
</span>
67+
<div class="p-3">
68+
69+
Remember that _all_ instances of your controller _must_ add the `<template data-shadowroot>` HTML. If an instance does not have the `<template data-shadowroot>` as a direct child, then the shadow DOM won't be rendered for it!
70+
71+
</div>
72+
</div>
73+
74+
### Updating a Template element using JS templates
75+
76+
Sometimes you wont have a template that is server rendered, and instead want to make a template using JS. Catalyst does not support this out of the box, but it is possible to use another library: `@github/jtml`. This library can be used to write declarative templates using JS. Let's re-work the above example using `@github/jtml`:
77+
78+
```typescript
79+
import { controller, target } from "@github/catalyst"
80+
import { html, render } from "@github/jtml"
81+
82+
@controller
83+
class HelloWorldElement extends HTMLElement {
84+
85+
// Make `name` automatically update when changed
86+
#name = 'World'
87+
get name() {
88+
return this.#name
89+
}
90+
set name(value: string) {
91+
this.#name = value
92+
this.update()
93+
}
94+
95+
connectedCallback() {
96+
this.attachShadow({mode: 'open'})
97+
this.update()
98+
}
99+
100+
update() {
101+
render(() => html`
102+
<div>
103+
Hello <span>${ this.name }</span>
104+
</div>`,
105+
this.shadowRoot!)
106+
}
107+
}
108+
```
109+
110+
Here, instead of declaring our template in HTML, we can do so in JS, and achieve exactly the same effect. We aren't using `@targets` in this example, as there is a more direct way to handle the data; re-calling `update()` will efficiently update only the parts that change.

src/auto-shadow-root.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function autoShadowRoot(element: HTMLElement): void {
2+
for (const template of element.querySelectorAll<HTMLTemplateElement>('template[data-shadowroot]')) {
3+
if (template.parentElement === element) {
4+
element
5+
.attachShadow({
6+
mode: template.getAttribute('data-shadowroot') === 'closed' ? 'closed' : 'open'
7+
})
8+
.appendChild(template.content.cloneNode(true))
9+
}
10+
}
11+
}

src/controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {register} from './register'
22
import {bind} from './bind'
3+
import {autoShadowRoot} from './auto-shadow-root'
34
import {wrap} from './wrap'
45

56
interface CustomElement {
@@ -15,6 +16,7 @@ interface CustomElement {
1516
export function controller(classObject: CustomElement): void {
1617
wrap(classObject.prototype, 'connectedCallback', function (this: HTMLElement) {
1718
this.toggleAttribute('data-catalyst', true)
19+
autoShadowRoot(this)
1820
bind(this)
1921
})
2022
register(classObject)

test/auto-shadow-root.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import {autoShadowRoot} from '../lib/auto-shadow-root.js'
2+
3+
describe('autoShadowRoot', () => {
4+
window.customElements.define('autoshadowroot-test-element', class extends HTMLElement {})
5+
6+
let root
7+
8+
beforeEach(() => {
9+
root = document.createElement('div')
10+
document.body.appendChild(root)
11+
})
12+
13+
afterEach(() => {
14+
root.remove()
15+
})
16+
17+
it('automatically declares shadowroot for elements with `template[data-shadowroot]` children', () => {
18+
const instance = document.createElement('shadowroot-test-element')
19+
const template = document.createElement('template')
20+
template.innerHTML = 'Hello World'
21+
template.setAttribute('data-shadowroot', 'open')
22+
instance.appendChild(template)
23+
24+
autoShadowRoot(instance)
25+
26+
expect(instance).to.have.property('shadowRoot').not.equal(null)
27+
expect(instance.shadowRoot.textContent).to.equal('Hello World')
28+
})
29+
30+
it('does not attach shadowroot without a template`data-shadowroot` child', () => {
31+
const instance = document.createElement('shadowroot-test-element')
32+
const template = document.createElement('template')
33+
template.setAttribute('data-notshadowroot', 'open')
34+
const otherTemplate = document.createElement('div')
35+
otherTemplate.setAttribute('data-shadowroot', 'open')
36+
instance.appendChild(template, otherTemplate)
37+
38+
autoShadowRoot(instance)
39+
40+
expect(instance).to.have.property('shadowRoot').equal(null)
41+
})
42+
43+
it('does not attach shadowroots which are not direct children of the element', () => {
44+
const instance = document.createElement('shadowroot-test-element')
45+
const div = document.createElement('div')
46+
const template = document.createElement('template')
47+
template.setAttribute('data-notshadowroot', 'open')
48+
div.appendChild(template)
49+
instance.appendChild(div)
50+
51+
autoShadowRoot(instance)
52+
53+
expect(instance).to.have.property('shadowRoot').equal(null)
54+
})
55+
56+
it('attaches shadowRoot nodes open by default', () => {
57+
const instance = document.createElement('shadowroot-test-element')
58+
const template = document.createElement('template')
59+
template.innerHTML = 'Hello World'
60+
template.setAttribute('data-shadowroot', '')
61+
instance.appendChild(template)
62+
63+
autoShadowRoot(instance)
64+
65+
expect(instance).to.have.property('shadowRoot').not.equal(null)
66+
expect(instance.shadowRoot.textContent).to.equal('Hello World')
67+
})
68+
69+
it('attaches shadowRoot nodes closed if `data-shadowroot` is `closed`', () => {
70+
const instance = document.createElement('shadowroot-test-element')
71+
const template = document.createElement('template')
72+
template.innerHTML = 'Hello World'
73+
template.setAttribute('data-shadowroot', 'closed')
74+
instance.appendChild(template)
75+
76+
let shadowRoot = null
77+
chai.spy.on(instance, 'attachShadow', (...args) => {
78+
shadowRoot = Element.prototype.attachShadow.apply(instance, args)
79+
return shadowRoot
80+
})
81+
82+
autoShadowRoot(instance)
83+
84+
expect(instance).to.have.property('shadowRoot').equal(null)
85+
expect(instance.attachShadow).to.have.been.called.once.with.exactly({mode: 'closed'})
86+
expect(shadowRoot.textContent).to.equal('Hello World')
87+
})
88+
})

0 commit comments

Comments
 (0)