diff --git a/docs/_guide/abilities.md b/docs/_guide/abilities.md
new file mode 100644
index 00000000..dabc750b
--- /dev/null
+++ b/docs/_guide/abilities.md
@@ -0,0 +1,50 @@
+---
+chapter: 14
+subtitle: Abilities
+hidden: true
+---
+
+Under the hood Catalyst's controller decorator is comprised of a handful of separate "abilities". Each of these abilities is created with the `createAbility` function. An "ability" is essentially a mixin or perhaps "higher order class". An ability takes a class and returns an extended class that adds additional behaviours. Importantly abilities are idempotent, meaning applying an ability to a class multiple times is safe and the ability is only applied once. By convention all abilities exported by Catalyst are suffixed with `able` which we think is a nice way to denote that something is an ability and should be used as such. Catalyst also exposes all of the tooling to create your own abilities in your own code which we'd encourage if you find yourself repeating patterns within components (if you think you've got a really useful ability, we'd love for you to contribute it)!
+
+### Using Abilities
+
+Abilities are fundementally just class decorators, and so can be used just like the `@controller` decorator. For example to add only the `actionable` decorator (which automatically binds events based on `data-action` attributes):
+
+```typescript
+import {actionable} from '@github/catalyst'
+
+@actionable
+class HelloWorld extends HTMLElement {
+}
+```
+
+### Using Marks
+
+Abilities also come with complementary field decorators which we call "marks" (we give them a distinctive name because they're a more restrictive subset of field decorators). Marks annotate fields which abilities can then extend with custom logic, both [Targets]({{ site.baseurl }}/guide/targets) and [Attrs]({{ site.baseurl }}/guide/attrs) are abilities that use marks. The `targetable` ability includes `target` & `targets` marks, and the `attrable` ability includes the `attr` mark. Marks decorate individual fields, like so:
+
+```typescript
+import {targetable, target, targets} from '@github/catalyst'
+
+@targetable
+class HelloWorldElement extends HTMLElement {
+ @target name
+ @targets people
+}
+```
+
+Marks _can_ decorate over fields, get/set functions, or class methods - but individual marks can set their own validation logic, for example enforcing a naming pattern or disallowing application on methods.
+
+### Built-In Abilities
+
+Catalyst ships with a set of built in abilities. The `@controller` decorator applies the following built-in abilities:
+
+- `controllable` - the base ability which other abilities require for functionality.
+- `targetable` - the ability to define `@target` and `@targets` properties. See [Targets]({{ site.baseurl }}/guide/targets) for more.
+- `actionable` - the ability to automatically bind events based on `data-action` attributes. See [Actions]({{ site.baseurl }}/guide/actions) for more.
+- `attrable` - the ability to define `@attr`s. See [Attrs]({{ site.baseurl }}/guide/attrs) for more.
+
+The `@controller` decorator also applies the `@register` decorator which automatically registers the element in the Custom Element registry, however this decorator isn't an "ability".
+
+The following abilities are shipped with Catalyst but require manually applying as they aren't considered critical functionality:
+
+ - `providable` - the ability to define `provider` and `consumer` properties. See [Providable]({{ side.baseurl }}/guide/providable) for more.
diff --git a/docs/_guide/providable.md b/docs/_guide/providable.md
new file mode 100644
index 00000000..91c798f5
--- /dev/null
+++ b/docs/_guide/providable.md
@@ -0,0 +1,145 @@
+---
+chapter: 15
+subtitle: The Provider pattern
+hidden: true
+---
+
+The [Provider pattern](https://www.patterns.dev/posts/provider-pattern/) allows for deeply nested children to ask ancestors for values. This can be useful for decoupling state inside a component, centralising it higher up in the DOM heirarchy. A top level container component might store values, and many children can consume those values, without having logic duplicated across the app. It's quite an abstract pattern so is better explained with examples...
+
+Say for example a set of your components are built to perform actions on a user, but need a User ID. One way to handle this is to set the User ID as an attribute on each element, but this can lead to a lot of duplication. Instead these actions can request the ID from a parent component, which can provide the User ID without creating an explicit relationship (which can lead to brittle code).
+
+The `@providable` ability allows a Catalyst controller to become a provider or consumer (or both) of one or many properties. To provide a property to nested controllers that ask for it, mark a property as `@provide`. To consume a property from a parent, mark a property as `@consume`. Let's try implementing the user actions using `@providable`:
+
+```typescript
+import {providable, consume, provide, controller} from '@github/catalyst'
+
+@providable
+@controller
+class BlockUser extends HTMLElement {
+ // This will request `userId`, and default to '' if not provided.
+ @consume userId = ''
+ // This will request `userName`, and default to '' if not provided.
+ @consume userName = ''
+
+ async handleEvent() {
+ if (confirm(`Would you like to block ${this.userName}?`)) {
+ await fetch(`/users/${userId}/delete`)
+ }
+ }
+}
+
+@providable
+@controller
+class FollowUser extends HTMLElement {
+ // This will request `userId`, and default to '' if not provided.
+ @consume userId = ''
+ // This will request `userName`, and default to '' if not provided.
+ @consume userName = ''
+
+ async handleEvent() {
+ if (confirm(`Would you like to follow ${this.userName}?`)) {
+ await fetch(`/users/${userId}/delete`)
+ }
+ }
+}
+
+@providable
+@controller
+class UserRow extends HTMLElement {
+ // This will provide `userId` as '123' to any nested children that request it.
+ @provide userId = '123'
+ // This will provide `userName` as 'Alex' to any nested children that request it.
+ @provide userId = 'Alex'
+}
+```
+
+```html
+
+
+
+
+```
+
+This shows how the basic pattern works, but `UserRow` having fixed strings isn't very useful. The `@provide` decorator can be combined with other decorators to make it more powerful, for example `@attr`:
+
+```typescript
+import {providable, consume, provide, @attr, controller} from '@github/catalyst'
+
+@providable
+@controller
+class UserRow extends HTMLElement {
+ @provide @attr userId = ''
+ @provide @attr userName = ''
+}
+```
+```html
+
+
+
+
+
+
+
+
+```
+
+Values aren't just limited to strings, they can be any type; for example functions, classes, or even other controllers! We could implement a custom dialog component which exists as a sibling and invoke it using providers and `@target`:
+
+
+```typescript
+import {providable, consume, provide, target, attr, controller} from '@github/catalyst'
+
+@providable
+@controller
+class UserList extends HTMLElement {
+ @provide @target dialog: UserDialogElement
+}
+
+@controller
+class UserDialog extends HTMLElement {
+ setTitle(title: string) {
+ this.title.textContent = title
+ }
+ confirm() {
+ this.show()
+ return this.untilClosed()
+ }
+ //...
+}
+
+@providable
+@controller
+class FollowUser extends HTMLElement {
+ // This will request `userId`, and default to '' if not provided.
+ @consume userId = ''
+ // This will request `userName`, and default to '' if not provided.
+ @consume userName = ''
+ // This will request `dialog`, defaulting it to `null` if not provided:
+ @consume dialog: UserDialog | null = null
+
+ async handleEvent() {
+ if (!this.dialog) return
+ this.dialog.setTitle(`Would you like to follow ${this.userName}?`)
+ if (await this.dialog.confirm()) {
+ await fetch(`/users/${this.userId}/delete`)
+ }
+ }
+}
+```
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+If you're interested to find out how the Provider pattern works, you can look at the [context community-protocol as part of webcomponents-cg](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md).
diff --git a/docs/_includes/sidebar.html b/docs/_includes/sidebar.html
index 7643936d..8c62fec0 100644
--- a/docs/_includes/sidebar.html
+++ b/docs/_includes/sidebar.html
@@ -2,12 +2,14 @@
diff --git a/package.json b/package.json
index b8342ca1..98aaaa57 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,12 @@
{
"path": "lib/index.js",
"import": "{controller, attr, target, targets}",
- "limit": "1.66kb"
+ "limit": "2.5kb"
+ },
+ {
+ "path": "lib/abilities.js",
+ "import": "{providable}",
+ "limit": "1.1kb"
}
]
}
diff --git a/src/abilities.ts b/src/abilities.ts
new file mode 100644
index 00000000..53da07a6
--- /dev/null
+++ b/src/abilities.ts
@@ -0,0 +1 @@
+export {provide, getProvide, consume, getConsume, providable} from './providable.js'
diff --git a/src/providable.ts b/src/providable.ts
new file mode 100644
index 00000000..5ec015a5
--- /dev/null
+++ b/src/providable.ts
@@ -0,0 +1,114 @@
+import type {CustomElementClass, CustomElement} from './custom-element.js'
+import {createMark} from './mark.js'
+import {createAbility} from './ability.js'
+
+export interface Context {
+ name: PropertyKey
+ initialValue?: T
+}
+export type ContextCallback = (value: ValueType, dispose?: () => void) => void
+export type ContextType> = T extends Context ? Y : never
+
+export class ContextEvent> extends Event {
+ public constructor(
+ public readonly context: T,
+ public readonly callback: ContextCallback>,
+ public readonly multiple?: boolean
+ ) {
+ super('context-request', {bubbles: true, composed: true})
+ }
+}
+
+function isContextEvent(event: unknown): event is ContextEvent> {
+ return (
+ event instanceof Event &&
+ event.type === 'context-request' &&
+ 'context' in event &&
+ 'callback' in event &&
+ 'multiple' in event
+ )
+}
+
+const contexts = new WeakMap void>>>()
+const [provide, getProvide, initProvide] = createMark(
+ ({name, kind}) => {
+ if (kind === 'setter') throw new Error(`@provide cannot decorate setter ${String(name)}`)
+ if (kind === 'method') throw new Error(`@provide cannot decorate method ${String(name)}`)
+ },
+ (instance: CustomElement, {name, kind, access}) => {
+ return {
+ get: () => (kind === 'getter' ? access.get!.call(instance) : access.value),
+ set: (newValue: unknown) => {
+ access.set?.call(instance, newValue)
+ for (const callback of contexts.get(instance)?.get(name) || []) callback(newValue)
+ }
+ }
+ }
+)
+const [consume, getConsume, initConsume] = createMark(
+ ({name, kind}) => {
+ if (kind === 'method') throw new Error(`@consume cannot decorate method ${String(name)}`)
+ },
+ (instance: CustomElement, {name, access}) => {
+ const initialValue: unknown = access.get?.call(instance) ?? access.value
+ let currentValue = initialValue
+ instance.dispatchEvent(
+ new ContextEvent(
+ {name, initialValue},
+ (value: unknown, dispose?: () => void) => {
+ if (!disposes.has(instance)) disposes.set(instance, new Map())
+ const instanceDisposes = disposes.get(instance)!
+ if (instanceDisposes.has(name)) instanceDisposes.get(name)!()
+ if (dispose) instanceDisposes.set(name, dispose)
+ currentValue = value
+ access.set?.call(instance, currentValue)
+ },
+ true
+ )
+ )
+ return {get: () => currentValue}
+ }
+)
+
+const disposes = new WeakMap void>>()
+
+export {consume, provide, getProvide, getConsume}
+export const providable = createAbility(
+ (Class: T): T =>
+ class extends Class {
+ [key: PropertyKey]: unknown
+
+ // TS mandates Constructors that get mixins have `...args: any[]`
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ constructor(...args: any[]) {
+ super(...args)
+ initProvide(this)
+ if (getProvide(this).size) {
+ this.addEventListener('context-request', event => {
+ if (!isContextEvent(event)) return
+ const name = event.context.name
+ const value = this[name]
+ const callback = event.callback
+ if (event.multiple) {
+ if (!contexts.has(this)) contexts.set(this, new Map())
+ const instanceContexts = contexts.get(this)!
+ if (!instanceContexts.has(name)) instanceContexts.set(name, new Set())
+ instanceContexts.get(name)!.add(callback)
+ }
+ callback(value, () => contexts.get(this)?.get(name)?.delete(callback))
+ })
+ }
+ }
+
+ connectedCallback() {
+ initConsume(this)
+ super.connectedCallback?.()
+ }
+
+ disconnectedCallback() {
+ for (const dispose of disposes.get(this)?.values() || []) {
+ dispose()
+ }
+ }
+ }
+)
diff --git a/test/providable.ts b/test/providable.ts
new file mode 100644
index 00000000..e63d16e9
--- /dev/null
+++ b/test/providable.ts
@@ -0,0 +1,224 @@
+import {expect, fixture, html} from '@open-wc/testing'
+import {fake} from 'sinon'
+import {provide, consume, providable, ContextEvent} from '../src/providable.js'
+
+describe('Providable', () => {
+ const sym = Symbol('bing')
+ @providable
+ class ProvidableProviderTest extends HTMLElement {
+ @provide foo = 'hello'
+ @provide bar = 'world'
+ @provide get baz() {
+ return 3
+ }
+ @provide [sym] = {provided: true}
+ @provide qux = 8
+ }
+ window.customElements.define('providable-provider-test', ProvidableProviderTest)
+
+ @providable
+ class ProvidableConsumerTest extends HTMLElement {
+ @consume foo = 'goodbye'
+ @consume bar = 'universe'
+ @consume get baz() {
+ return 1
+ }
+ @consume [sym] = {}
+ count = 0
+ get qux() {
+ return this.count
+ }
+ @consume set qux(value: number) {
+ this.count += 1
+ }
+ connectedCallback() {
+ this.textContent = `${this.foo} ${this.bar}`
+ }
+ }
+ window.customElements.define('providable-consumer-test', ProvidableConsumerTest)
+
+ describe('consumer without provider', () => {
+ let instance: ProvidableConsumerTest
+ beforeEach(async () => {
+ instance = await fixture(html``)
+ })
+
+ it('uses the given values', () => {
+ expect(instance).to.have.property('foo', 'goodbye')
+ expect(instance).to.have.property('bar', 'universe')
+ expect(instance).to.have.property('baz', 1)
+ expect(instance).to.have.property(sym).eql({})
+ expect(instance).to.have.property('textContent', 'goodbye universe')
+ })
+
+ it('overrides the property definitions to not be setters', () => {
+ expect(() => (instance.foo = 'hello')).to.throw()
+ expect(() => (instance.bar = 'world')).to.throw()
+ // @ts-expect-error this was only a getter to begin with
+ expect(() => (instance.baz = 3)).to.throw()
+ })
+
+ it('emits the `context-request` event when connected, for each field', async () => {
+ instance = document.createElement('providable-consumer-test') as ProvidableConsumerTest
+ const events = fake()
+ instance.addEventListener('context-request', events)
+ await fixture(instance)
+
+ expect(events).to.have.callCount(5)
+ const fooEvent = events.getCall(0).args[0]
+ expect(fooEvent).to.be.instanceof(ContextEvent)
+ expect(fooEvent).to.have.nested.property('context.name', 'foo')
+ expect(fooEvent).to.have.nested.property('context.initialValue', 'goodbye')
+ expect(fooEvent).to.have.property('multiple', true)
+ expect(fooEvent).to.have.property('bubbles', true)
+
+ const barEvent = events.getCall(1).args[0]
+ expect(barEvent).to.be.instanceof(ContextEvent)
+ expect(barEvent).to.have.nested.property('context.name', 'bar')
+ expect(barEvent).to.have.nested.property('context.initialValue', 'universe')
+ expect(barEvent).to.have.property('multiple', true)
+ expect(barEvent).to.have.property('bubbles', true)
+
+ const bazEvent = events.getCall(2).args[0]
+ expect(bazEvent).to.be.instanceof(ContextEvent)
+ expect(bazEvent).to.have.nested.property('context.name', 'baz')
+ expect(bazEvent).to.have.nested.property('context.initialValue', 1)
+ expect(bazEvent).to.have.property('multiple', true)
+ expect(bazEvent).to.have.property('bubbles', true)
+
+ const bingEvent = events.getCall(3).args[0]
+ expect(bingEvent).to.be.instanceof(ContextEvent)
+ expect(bingEvent).to.have.nested.property('context.name', sym)
+ expect(bingEvent).to.have.nested.property('context.initialValue').eql({})
+ expect(bingEvent).to.have.property('multiple', true)
+ expect(bingEvent).to.have.property('bubbles', true)
+
+ const quxEvent = events.getCall(4).args[0]
+ expect(quxEvent).to.be.instanceof(ContextEvent)
+ expect(quxEvent).to.have.nested.property('context.name', 'qux')
+ expect(quxEvent).to.have.nested.property('context.initialValue').eql(0)
+ expect(quxEvent).to.have.property('multiple', true)
+ expect(quxEvent).to.have.property('bubbles', true)
+ })
+ })
+
+ describe('provider', () => {
+ let provider: ProvidableProviderTest
+ beforeEach(async () => {
+ provider = await fixture(
+ html`