From cb47afdda034a8fc2d77ba6046a29098a83d9052 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Thu, 22 Sep 2022 16:13:04 +0100 Subject: [PATCH] add slottable behavior --- docs/_guide/actions.md | 2 +- docs/_guide/anti-patterns.md | 2 +- docs/_guide/attrs.md | 2 +- docs/_guide/conventions.md | 2 +- docs/_guide/lifecycle-hooks.md | 2 +- docs/_guide/patterns.md | 2 +- docs/_guide/rendering.md | 2 +- docs/_guide/slottable.md | 106 +++++++++++++++++++++++++++++++++ docs/_guide/testing.md | 2 +- src/slottable.ts | 92 ++++++++++++++++++++++++++++ test/slottable.ts | 75 +++++++++++++++++++++++ 11 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 docs/_guide/slottable.md create mode 100644 src/slottable.ts create mode 100644 test/slottable.ts diff --git a/docs/_guide/actions.md b/docs/_guide/actions.md index 0fcf4f77..0d4f32c1 100644 --- a/docs/_guide/actions.md +++ b/docs/_guide/actions.md @@ -1,5 +1,5 @@ --- -chapter: 6 +chapter: 7 subtitle: Binding Events --- diff --git a/docs/_guide/anti-patterns.md b/docs/_guide/anti-patterns.md index 362f8844..447cb91a 100644 --- a/docs/_guide/anti-patterns.md +++ b/docs/_guide/anti-patterns.md @@ -1,5 +1,5 @@ --- -chapter: 12 +chapter: 13 subtitle: Anti Patterns --- diff --git a/docs/_guide/attrs.md b/docs/_guide/attrs.md index 1947af2e..5306e6b7 100644 --- a/docs/_guide/attrs.md +++ b/docs/_guide/attrs.md @@ -1,5 +1,5 @@ --- -chapter: 7 +chapter: 8 subtitle: Using attributes as configuration --- diff --git a/docs/_guide/conventions.md b/docs/_guide/conventions.md index 4d9d01fd..22d026ae 100644 --- a/docs/_guide/conventions.md +++ b/docs/_guide/conventions.md @@ -1,5 +1,5 @@ --- -chapter: 10 +chapter: 11 subtitle: Conventions --- diff --git a/docs/_guide/lifecycle-hooks.md b/docs/_guide/lifecycle-hooks.md index 924880fc..a9b8f222 100644 --- a/docs/_guide/lifecycle-hooks.md +++ b/docs/_guide/lifecycle-hooks.md @@ -1,5 +1,5 @@ --- -chapter: 8 +chapter: 9 subtitle: Observing the life cycle of an element --- diff --git a/docs/_guide/patterns.md b/docs/_guide/patterns.md index 3d14bbb7..def216f7 100644 --- a/docs/_guide/patterns.md +++ b/docs/_guide/patterns.md @@ -1,5 +1,5 @@ --- -chapter: 11 +chapter: 12 subtitle: Patterns --- diff --git a/docs/_guide/rendering.md b/docs/_guide/rendering.md index c380a92f..8334356d 100644 --- a/docs/_guide/rendering.md +++ b/docs/_guide/rendering.md @@ -1,5 +1,5 @@ --- -chapter: 8 +chapter: 10 subtitle: Rendering HTML subtrees --- diff --git a/docs/_guide/slottable.md b/docs/_guide/slottable.md new file mode 100644 index 00000000..1df8a803 --- /dev/null +++ b/docs/_guide/slottable.md @@ -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 [``](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 `` elements do not require any JavaScript to work, it can be very useful to know when the contents of a `` 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!

+

Hola Mundo!

+

Bonjour le monde!

+
+``` + +```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 + + + +

Hello World!

+

Hola Mundo!

+

Bonjour le monde!

+ +``` + +```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 + + + +``` + +```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] +} +) +``` diff --git a/docs/_guide/testing.md b/docs/_guide/testing.md index 9bf224be..c0ad2b64 100644 --- a/docs/_guide/testing.md +++ b/docs/_guide/testing.md @@ -1,5 +1,5 @@ --- -chapter: 13 +chapter: 14 subtitle: Testing --- diff --git a/src/slottable.ts b/src/slottable.ts new file mode 100644 index 00000000..34e13e09 --- /dev/null +++ b/src/slottable.ts @@ -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(`slot${key === mainSlot ? `:not([name])` : `[name=${dasherize(key)}]`}`) ?? null + +const [slot, getSlot, initSlot] = createMark( + ({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() +const oldValues = new WeakMap>() +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)[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)[key] = slotEl +} + +export {slot, getSlot} +const shadows = new WeakMap() +const slotNameMap = new WeakMap>() +export const slottable = createAbility( + (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) + } + } +) diff --git a/test/slottable.ts b/test/slottable.ts new file mode 100644 index 00000000..d70ac225 --- /dev/null +++ b/test/slottable.ts @@ -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` + + + + + ` + } + } + window.customElements.define('slottable-test', SlottableTest) + + let instance: SlottableTest + beforeEach(async () => { + instance = await fixture(html``) + }) + + 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`

Foo

` + await Promise.resolve() + expect(instance).to.have.property('count', 2) + expect(instance).to.have.property('assigned', 1) + instance.innerHTML += html`

Bar

` + 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) + }) +})