Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add slottable behavior #283

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion docs/_guide/actions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 6
chapter: 7
subtitle: Binding Events
---

Expand Down
2 changes: 1 addition & 1 deletion docs/_guide/anti-patterns.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 12
chapter: 13
subtitle: Anti Patterns
---

Expand Down
2 changes: 1 addition & 1 deletion docs/_guide/attrs.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 7
chapter: 8
subtitle: Using attributes as configuration
---

Expand Down
2 changes: 1 addition & 1 deletion docs/_guide/conventions.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 10
chapter: 11
subtitle: Conventions
---

Expand Down
2 changes: 1 addition & 1 deletion docs/_guide/lifecycle-hooks.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 8
chapter: 9
subtitle: Observing the life cycle of an element
---

Expand Down
2 changes: 1 addition & 1 deletion docs/_guide/patterns.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 11
chapter: 12
subtitle: Patterns
---

Expand Down
2 changes: 1 addition & 1 deletion docs/_guide/rendering.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 8
chapter: 10
subtitle: Rendering HTML subtrees
---

Expand Down
106 changes: 106 additions & 0 deletions docs/_guide/slottable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
chapter: 6
subtitle: Quering Slots
hidden: true
---

Similar to [`@target`]({{ site.baseurl }}/guide/targets), Catalyst includes an `@slot` decorator which allows for querying [`<slot>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Slot) elements within a [ShadowRoot](https://developer.mozilla.org/en-US/docs/Web/API/ShadowRoot). Slots are useful for having interchangeable content within a components shadow tree. You can read more about [Slots on MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Slot).

While `<slot>` elements do not require any JavaScript to work, it can be very useful to know when the contents of a `<slot>` have changed. By using the `@slot` decorator over a setter field, any time the slot or changes, including when the assigned nodes change, the setter will be called:

```html
<hello-world>
<template shadowroot="open">
<slot name="greeting"></slot>

We have <span data-target="hello-world.count">0</span> greetings!
</template>

<p slot="greeting">Hello World!</p>
<p slot="greeting">Hola Mundo!</p>
<p slot="greeting">Bonjour le monde!</p>
</hello-world>
```

```typescript
import {slot, controller} from '@github/catalyst'

@controller
class HelloWorld extends HTMLElement {
@target count: HTMLElement

@slot set greeting(slot: HTMLSlotElement) {
this.count.textContent = slot.assignedNodes().length
}
}
```

### Slot naming

The `@slot` decorator works just like `@target` or `@attr`, in that the camel-cased property name is _dasherized_ when serialised to HTML. Take a look at the following examples:

```html
<slot-naming-example>
<template shadowroot="open">
<slot name="hello-world"></slot>
<slot name=""></slot>
</template>

<p slot="greeting">Hello World!</p>
<p slot="greeting">Hola Mundo!</p>
<p slot="greeting">Bonjour le monde!</p>
</hello-world>
```

```typescript
import {slot, controller} from '@github/catalyst'

@controller
class HelloWorld extends HTMLElement {
@target count: HTMLElement

@slot set greeting(slot: HTMLSlotElement) {
this.count.textContent = slot.assignedNodes().length
}
}
```


### The un-named "main" slot

ShadowRoots can also have an "unnamed slots", which by default this will contain all of the elements top-level child elements that don't have a `slot` attribute. As this slot does not have a name, it cannot easily map to a property on the class. For this we have a special `mainSlot` symbol which can be used to refer to the "unnamed slot" or "main slot":

```html
<user-greeting>
<template shadowroot="open">
<slot></slot>!
</template>
</user-greeting>
```

```typescript
import {slot, mainSlot, controller} from '@github/catalyst'

@controller
class HelloWorld extends HTMLElement {
@slot [mainSlot]: HTMLSlotElement

connectedCallback() {
console.log(this[mainSlot].assignedNodes)
}
}
```

### What about without Decorators?

If you're not using decorators, then `@slot` has an escape hatch: you can define a static class field using the `[slot.static]` computed property, as an array of key names. Like so:

```js
import {controller, mainSlot, slot} from '@github/catalyst'
controller(
class HelloWorldElement extends HTMLElement {
// Same as @slot fooBar
[slot.static] = ['fooBar', mainSlot]
}
)
```
2 changes: 1 addition & 1 deletion docs/_guide/testing.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
chapter: 13
chapter: 14
subtitle: Testing
---

Expand Down
92 changes: 92 additions & 0 deletions src/slottable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type {CustomElementClass} from './custom-element.js'
import {controllable, attachShadowCallback} from './controllable.js'
import {createMark} from './mark.js'
import {createAbility} from './ability.js'
import {dasherize} from './dasherize.js'

export const mainSlot = Symbol()

const getSlotEl = (root?: ShadowRoot, key?: PropertyKey) =>
root?.querySelector<HTMLSlotElement>(`slot${key === mainSlot ? `:not([name])` : `[name=${dasherize(key)}]`}`) ?? null

const [slot, getSlot, initSlot] = createMark<Element>(
({name, kind}) => {
if (kind === 'getter') throw new Error(`@slot cannot decorate get ${String(name)}`)
if (kind === 'method') throw new Error(`@slot cannot decorate method ${String(name)}`)
},
(instance: Element, {name, access}) => {
return {
get: () => applySlot(instance, name),
set: () => {
access.set?.call(instance, getSlotEl(shadows.get(instance), name))
}
}
}
)

const slotObserver = new MutationObserver(mutations => {
const seen = new WeakSet()
for (const mutation of mutations) {
const el = mutation.target
const controller = (el.getRootNode() as ShadowRoot).host
if (seen.has(controller)) continue
seen.add(controller)
let slotHasChanged = el instanceof HTMLSlotElement
if (!slotHasChanged && mutation.addedNodes) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLSlotElement) {
slotHasChanged = true
break
}
}
}
if (slotHasChanged) for (const key of getSlot(controller)) applySlot(controller, key)
}
})
const slotObserverOptions = {childList: true, subtree: true, attributeFilter: ['name']}

const listened = new WeakSet<HTMLSlotElement>()
const oldValues = new WeakMap<Element, Map<PropertyKey, HTMLSlotElement | null>>()
function applySlot(controller: Element, key: PropertyKey) {
if (!oldValues.has(controller)) oldValues.set(controller, new Map())
if (!slotNameMap.has(controller)) slotNameMap.set(controller, new WeakMap())
const oldSlot = oldValues.get(controller)!.get(key)
const newSlot = getSlotEl(shadows.get(controller), key)
oldValues.get(controller)!.set(key, newSlot)
if (newSlot && !listened.has(newSlot)) {
slotNameMap.get(controller)!.set(newSlot, key)
newSlot.addEventListener('slotchange', handleSlotChange)
listened.add(newSlot)
}
if (oldSlot !== newSlot) (controller as unknown as Record<PropertyKey, HTMLSlotElement | null>)[key] = newSlot
return newSlot
}

function handleSlotChange(event: Event) {
const slotEl = event.target
if (!(slotEl instanceof HTMLSlotElement)) return
const controller = (slotEl.getRootNode() as ShadowRoot).host
const key = slotNameMap.get(controller)?.get(slotEl)
if (key) (controller as unknown as Record<PropertyKey, HTMLSlotElement>)[key] = slotEl
}

export {slot, getSlot}
const shadows = new WeakMap<Element, ShadowRoot>()
const slotNameMap = new WeakMap<Element, WeakMap<HTMLSlotElement, PropertyKey>>()
export const slottable = createAbility(
<T extends CustomElementClass>(Class: T): T =>
class extends controllable(Class) {
// TS mandates Constructors that get mixins have `...args: any[]`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: any[]) {
super(...args)
initSlot(this)
}

[attachShadowCallback](shadowRoot: ShadowRoot) {
super[attachShadowCallback]?.(shadowRoot)
shadows.set(this, shadowRoot)
slotObserver.observe(shadowRoot, slotObserverOptions)
}
}
)
75 changes: 75 additions & 0 deletions test/slottable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {expect, fixture} from '@open-wc/testing'
import {slot, mainSlot, slottable} from '../src/slottable.js'
const html = String.raw

describe('Slottable', () => {
const sym = Symbol('bingBaz')
@slottable
class SlottableTest extends HTMLElement {
@slot declare foo: HTMLSlotElement

count = 0
assigned = -1
@slot set bar(barSlot: HTMLSlotElement) {
this.assigned = barSlot?.assignedElements().length ?? -2
this.count += 1
}

@slot declare [sym]: HTMLSlotElement;
@slot declare [mainSlot]: HTMLSlotElement

connectedCallback() {
this.attachShadow({mode: 'open'}).innerHTML = html`
<slot name="foo"></slot>
<slot name="bar"></slot>
<slot name="bing-baz"></slot>
<slot></slot>
`
}
}
window.customElements.define('slottable-test', SlottableTest)

let instance: SlottableTest
beforeEach(async () => {
instance = await fixture(html`<slottable-test />`)
})

it('queries the shadow root for the named slot', () => {
expect(instance).to.have.property('foo').to.be.instanceof(HTMLSlotElement).with.attribute('name', 'foo')
expect(instance).to.have.property('bar').to.be.instanceof(HTMLSlotElement).with.attribute('name', 'bar')
expect(instance).to.have.property(sym).to.be.instanceof(HTMLSlotElement).with.attribute('name', 'bing-baz')
expect(instance).to.have.property(mainSlot).to.be.instanceof(HTMLSlotElement).not.with.attribute('name')
})

it('calls setter on each change of the slots assigned nodes', async () => {
expect(instance).to.have.property('count', 1)
expect(instance).to.have.property('assigned', 0)
instance.innerHTML = html`<p slot="bar">Foo</p>`
await Promise.resolve()
expect(instance).to.have.property('count', 2)
expect(instance).to.have.property('assigned', 1)
instance.innerHTML += html`<p slot="bar">Bar</p>`
await Promise.resolve()
expect(instance).to.have.property('count', 3)
expect(instance).to.have.property('assigned', 2)
instance.innerHTML = ''
await Promise.resolve()
expect(instance).to.have.property('count', 4)
expect(instance).to.have.property('assigned', 0)
})

it('calls setter on each change of the slot', async () => {
expect(instance).to.have.property('count', 1)
expect(instance).to.have.property('assigned', 0)
instance.shadowRoot!.querySelector('slot[name="bar"]')!.setAttribute('name', 'tmp')
await Promise.resolve()
expect(instance.bar).to.equal(null)
expect(instance).to.have.property('count', 2)
expect(instance).to.have.property('assigned', -2)
instance.shadowRoot!.querySelector('slot[name="tmp"]')!.setAttribute('name', 'bar')
await Promise.resolve()
expect(instance.bar).to.be.an.instanceof(HTMLSlotElement).with.attribute('name', 'bar')
expect(instance).to.have.property('count', 3)
expect(instance).to.have.property('assigned', 0)
})
})