Skip to content

Commit 514b4ed

Browse files
authored
Merge branch 'master' into docs-add-interactive-name-checker
2 parents 53fe1f5 + 8d70063 commit 514b4ed

15 files changed

Lines changed: 685 additions & 130 deletions

docs/_data/reference.json

Lines changed: 454 additions & 77 deletions
Large diffs are not rendered by default.

docs/_guide/actions.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,26 @@ If you're using decorators, then the `@controller` decorator automatically handl
144144

145145
If you're not using decorators, then you'll need to call `bind(this)` somewhere inside of `connectedCallback()`.
146146

147-
```
147+
```js
148148
import {bind} from '@github/catalyst'
149149

150150
class HelloWorldElement extends HTMLElement {
151-
152151
connectedCallback() {
153152
bind(this)
154153
}
155-
156154
}
157155
```
156+
157+
### Binding dynamically added actions
158+
159+
Catalyst doesn't automatically bind actions to elements that are dynamically injected into the DOM. If you need to dynamically inject actions (for example you're injecting HTML via AJAX) you can call the `listenForBind` function to set up a observer that will bind actions when they are added to a controller.
160+
161+
You can provide the element you'd like to observe as a first argument and the number of items to process in a batch as a second argument. Those arguments default to `document` and `30` respectively.
162+
163+
Batch processing binds events in small batches to maintain UI stability (using `requestAnimationFrame` behind the scenes). We recommend the default of `30` as a sensible default, but you may find changing this number helps depending on your requirements.
164+
165+
```js
166+
import {listenForBind} from '@github/catalyst'
167+
168+
listenForBind(document, 30)
169+
```

docs/_guide/anti-patterns.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,15 @@ class UserFilter {
304304
<user-list>
305305
<label><input type="checkbox"
306306
data-action="change:user-list.filter"
307-
data-target="user-list.filters"
307+
data-targets="user-list.filters"
308308
data-filter="all">Show all</label>
309309
<label><input type="checkbox"
310310
data-action="change:user-list.filter"
311-
data-target="user-list.filters"
311+
data-targets="user-list.filters"
312312
data-filter="new">New Users</label>
313313
<label><input type="checkbox"
314314
data-action="change:user-list.filter"
315-
data-target="user-list.filters"
315+
data-targets="user-list.filters"
316316
data-filter="admin">Admins</label>
317317
<!-- ... --->
318318
</user-filter>
@@ -344,15 +344,16 @@ class UserFilter {
344344
<user-filter>
345345
<label><input type="checkbox"
346346
data-action="change:user-list.filter"
347-
data-target="user-list.filters user-list.allFilter"
347+
data-target="user-list.allFilter"
348+
data-targets="user-list.filters"
348349
data-filter="all">Show all</label>
349350
<label><input type="checkbox"
350351
data-action="change:user-list.filter"
351-
data-target="user-list.filters"
352+
data-targets="user-list.filters"
352353
data-filter="new">New Users</label>
353354
<label><input type="checkbox"
354355
data-action="change:user-list.filter"
355-
data-target="user-list.filters"
356+
data-targets="user-list.filters"
356357
data-filter="admin">Admins</label>
357358
<!-- ... --->
358359
</user-filter>

docs/_guide/conventions.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@ Be careful not to go too short! We'd recommend avoiding contracting words such a
3535
### Method names should describe what they do
3636

3737
A good method name, much like a good class name, describes what it does, not how it was invoked. While methods can be given most names, you should avoid names that conflict with existing methods on the `HTMLElement` prototype (more on that in [anti-patterns](/guide/anti-patterns#avoid-shadowing-method-names)). Names like `onClick` are best avoided, overly generic names like `toggle` should also be avoided. Just like class names it is a good idea to ask "how" and "what", so for example `showAdmins`, `filterUsers`, `updateURL`.
38+
39+
### `@target` should use singular naming, while `@targets` should use plural
40+
41+
To help differentiate the two `@target`/`@targets` decorators, the properties should be named with respective to their cardinality. That is to say, if you're using an `@target` decorator, then the name should be singular (e.g. `user`, `field`) while the `@targets` decorator should be coupled with plural property names (e.g. `users`, `fields`).

docs/_guide/targets.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ chapter: 5
33
subtitle: Querying Descendants
44
---
55

6-
One of the three [core patterns](/guide/introduction#three-core-concepts-observe-listen-query) is Querying. In Catalyst, Targets are the preferred way to query. Targets use `querySelectorAll` under the hood, but in a way that makes it a lot simpler to work with.
6+
One of the three [core patterns](/guide/introduction#three-core-concepts-observe-listen-query) is Querying. In Catalyst, Targets are the preferred way to query. Targets use `querySelector` under the hood, but in a way that makes it a lot simpler to work with.
77

88
Catalyst Components are really just Web Components, so you could simply use `querySelector` or `querySelectorAll` to select descendants of the element. Targets avoid some of the problems of `querySelector`; they provide a more consistent interface, avoid coupling CSS classes or HTML tag names to JS, and they handle subtle issues like nested components. Targets are also a little more ergonomic to reuse in a class. We'd recommend using Targets over `querySelector` wherever you can.
99

@@ -61,25 +61,25 @@ The target syntax follows a pattern of `controller.target`.
6161
</span>
6262
<div class="p-3">
6363

64-
Remember! There are two decorators available, `@target` which fetches only one element, and `@targets` which fetches multiple. This is the only difference, but it's an important one.
64+
Remember! There are two decorators available, `@target` which fetches only one `data-target` element, and `@targets` which fetches multiple `data-targets` elements!
6565

6666
</div>
6767
</div>
6868

69-
The `@target` decorator will only ever return _one_ element, just like `querySelector`. If you want to get multiple Targets, you need the `@targets` decorator which works almost identically, but it'll return an _array_ of elements. To put this into types: `@target` returns `Element|undefined` while `@targets` returns `Array<Element>`
69+
The `@target` decorator will only ever return _one_ element, just like `querySelector`. If you want to get multiple Targets, you need the `@targets` decorator which works almost the same, but returns an Array of elements, and it searches the `data-targets` attribute (not `data-target`).
7070

7171
Elements can be referenced as multiple targets, and targets may be referenced multiple times within the HTML:
7272

7373
```html
7474
<team-members>
7575
<user-list>
76-
<user-settings data-target="user-list.user">
77-
<input type="checkbox" data-target="team-members.read user-settings.read">
78-
<input type="checkbox" data-target="team-members.write user-settings.write">
76+
<user-settings data-targets="user-list.users">
77+
<input type="checkbox" data-targets="team-members.reads user-settings.reads">
78+
<input type="checkbox" data-targets="team-members.writes user-settings.writes">
7979
</user-settings>
80-
<user-settings data-target="user-list.user">
81-
<input type="checkbox" data-target="team-members.read user-settings.read">
82-
<input type="checkbox" data-target="team-members.write user-settings.write">
80+
<user-settings data-targets="user-list.users">
81+
<input type="checkbox" data-targets="team-members.reads user-settings.reads">
82+
<input type="checkbox" data-targets="team-members.writes user-settings.writes">
8383
</user-settings>
8484
</user-list>
8585
</team-members>
@@ -112,6 +112,15 @@ class UserListElement extends HTMLElement {
112112
}
113113
```
114114

115+
### Target Vs Targets
116+
117+
To clarify the difference between `@target` and `@targets` here is a handy table:
118+
119+
| Decorator | Equivalent Native Method | Selector | Returns |
120+
|:-----------|:-------------------------|:-------------------|:-----------------|
121+
| `@target` | `querySelector` | `data-target="*"` | `Element` |
122+
| `@targets` | `querySelectorAll` | `data-targets="*"` | `Array<Element>` |
123+
115124
### What about without Decorators?
116125

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

docs/_layouts/guide.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
{% include sidebar.html %}
88

99
<section class="col-9 px-5 f4">
10-
<div class="container-md markdown-body">
10+
<div class="container-md markdown-body mb-5">
1111
<h1 class="mb-4 f0-light">{{ page.title }}</h1>
1212
{{ content }}
1313
</div>

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@github/catalyst",
3-
"version": "0.1.0",
3+
"version": "0.3.0",
44
"description": "Helpers for creating HTML Elements as Controllers",
55
"homepage": "https://github.github.io/catalyst",
66
"bugs": {

src/bind.ts

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
const bound = new Set<string>()
12
/*
23
* Bind `[data-action]` elements from the DOM to their actions.
34
*
45
*/
56
export function bind(controller: HTMLElement): void {
67
const tag = controller.tagName.toLowerCase()
8+
bound.add(tag)
79
const actionAttributeMatcher = `[data-action*=":${tag}#"]`
810

911
for (const el of controller.querySelectorAll(actionAttributeMatcher)) {
@@ -18,32 +20,103 @@ export function bind(controller: HTMLElement): void {
1820
}
1921
}
2022

21-
// Bind the data-action attribute of a single element to the controller
22-
function bindActionsToController(controller: HTMLElement, el: Element) {
23-
const tag = controller.tagName.toLowerCase()
24-
25-
// Match the pattern of `eventName:constructor#method`.
23+
// Match the pattern of `eventName:constructor#method`.
24+
function* getActions(el: Element): Generator<[string, string, string]> {
2625
for (const binding of (el.getAttribute('data-action') || '').split(' ')) {
27-
const [rest, method] = binding.split('#')
26+
const [rest, methodName] = binding.split('#')
27+
if (!methodName) continue
2828

2929
// eventName may contain `:` so account for that
3030
// by splitting by the last instance of `:`
3131
const colonIndex = rest.lastIndexOf(':')
3232
if (colonIndex < 0) continue
3333

34-
const handler = rest.slice(colonIndex + 1)
35-
if (handler !== tag) continue
34+
yield [rest.slice(0, colonIndex), rest.slice(colonIndex + 1), methodName]
35+
}
36+
}
37+
38+
function bindActionToController(controller: HTMLElement, el: Element, methodName: string, eventName: string) {
39+
// Check the `method` is present on the prototype
40+
const methodDescriptor =
41+
Object.getOwnPropertyDescriptor(controller, methodName) ||
42+
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), methodName)
43+
if (methodDescriptor && typeof methodDescriptor.value == 'function') {
44+
el.addEventListener(eventName, (event: Event) => {
45+
methodDescriptor.value.call(controller, event)
46+
})
47+
}
48+
}
3649

37-
const eventName = rest.slice(0, colonIndex)
50+
// Bind the data-action attribute of a single element to the controller
51+
function bindActionsToController(controller: HTMLElement, el: Element) {
52+
const tag = controller.tagName.toLowerCase()
3853

39-
// Check the `method` is present on the prototype
40-
const methodDescriptor =
41-
Object.getOwnPropertyDescriptor(controller, method) ||
42-
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(controller), method)
43-
if (methodDescriptor && typeof methodDescriptor.value == 'function') {
44-
el.addEventListener(eventName, (event: Event) => {
45-
methodDescriptor.value.call(controller, event)
46-
})
54+
for (const [eventName, tagName, methodName] of getActions(el)) {
55+
if (tagName === tag) {
56+
bindActionToController(controller, el, methodName, eventName)
4757
}
4858
}
4959
}
60+
61+
interface Subscription {
62+
closed: boolean
63+
unsubscribe(): void
64+
}
65+
66+
/**
67+
* Set up observer that will make sure any actions that are dynamically
68+
* injected into `el` will be bound to it's controller.
69+
*
70+
* This returns a Subscription object which you can call `unsubscribe()` on to
71+
* stop further live updates.
72+
*/
73+
export function listenForBind(el = document, batchSize = 30): Subscription {
74+
let closed = false
75+
76+
const observer = new MutationObserver(mutations => {
77+
const queue = new Set<Element>()
78+
for (const mutation of mutations) {
79+
if (mutation.type === 'childList' && mutation.addedNodes.length) {
80+
for (const node of mutation.addedNodes) {
81+
if (!(node instanceof Element)) continue
82+
if (node.hasAttribute('data-action')) {
83+
queue.add(node)
84+
}
85+
}
86+
}
87+
}
88+
if (queue.size) requestAnimationFrame(() => processQueue(queue, batchSize))
89+
})
90+
91+
observer.observe(el, {childList: true, subtree: true})
92+
93+
return {
94+
get closed() {
95+
return closed
96+
},
97+
unsubscribe() {
98+
closed = true
99+
observer.disconnect()
100+
}
101+
}
102+
}
103+
104+
function processQueue(queue: Set<Element>, batchSize: number) {
105+
let counter = batchSize
106+
for (const el of queue) {
107+
for (const [eventName, controllerTag, methodName] of getActions(el)) {
108+
if (!bound.has(controllerTag)) continue
109+
const controller = el.closest(controllerTag)
110+
if (!(controller instanceof HTMLElement)) continue
111+
112+
bindActionToController(controller, el, methodName, eventName)
113+
}
114+
queue.delete(el)
115+
116+
counter -= 1
117+
if (counter === 0) break
118+
}
119+
if (queue.size !== 0) {
120+
requestAnimationFrame(() => processQueue(queue, batchSize))
121+
}
122+
}

src/findtarget.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function findTarget(controller: HTMLElement, name: string): Element | und
2020
export function findTargets(controller: HTMLElement, name: string): Element[] {
2121
const tag = controller.tagName.toLowerCase()
2222
const targets = []
23-
for (const el of controller.querySelectorAll(`[data-target~="${tag}.${name}"]`)) {
23+
for (const el of controller.querySelectorAll(`[data-targets~="${tag}.${name}"]`)) {
2424
if (el.closest(tag) === controller) targets.push(el)
2525
}
2626
return targets

0 commit comments

Comments
 (0)