-
Notifications
You must be signed in to change notification settings - Fork 2
/
mini-island.js
354 lines (323 loc) · 10.3 KB
/
mini-island.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
/**
* Define a MiniIsland class to encapsulate the behavior of
our custom element, <mini-island>
* This class extends HTMLElement where the HTMLElement
interface represents any HTML element.
*/
class MiniIsland extends HTMLElement {
/**
* Define the name for the custom element as a static class
property.
* Custom element names require a dash to be used in them
(kebab-case).
* The name can't be a single word. ✅ mini-island ❌
miniIsland
*/
static tagName = "mini-island";
static attributes = {
dataIsland: "data-island",
};
/**
* The connectedCallback is a part of the custom elements lifecycle callback.
* It is invoked anytime the custom element is attached to the DOM
*/
async connectedCallback() {
/**
* As soon as the island is connected, we will go ahead and hydrate the island
*/
await this.hydrate();
}
getTemplates() {
/**
* querySelectorAll() returns a list of the document's elements that match the specified group of selectors.
* The selector in this case is of the form "template[data-island]"
* i.e., this.querySelectorAll("template[data-island]")
*/
return this.querySelectorAll(
`template[${MiniIsland.attributes.dataIsland}]`
);
}
replaceTemplates(templates) {
/**
* Iterate over all nodes in the template list.
* templates refer to a NodeList of templates
* node refers to a single <template>
*/
for (const node of templates) {
/**
* Grab the HTML content within each <template>
*/
let html = node.innerHTML;
/**
* replace the <template> with its HTML content
* e.g., <template><p>Hello</p></template> becomes <p>Hello</p>
*/
node.replaceWith(node.content);
}
}
async hydrate() {
/**
* conditions will hold an array of potential condition promises
* to be resolved before hydration
*/
const conditions = [];
/**
* Get the condition - attribute value map
* NB: the argument passed to `Conditions.getConditions` is the island node
*/
const conditionAttributesMap = Conditions.getConditions(this);
/**
* Loop over the conditionAttributesMap variable
*/
for (const condition in conditionAttributesMap) {
/**
* Grab the condition function from the static Conditions map
* Remember that this refers to a function that returns a promise when invoked
*/
const conditionFn = Conditions.map[condition];
/**
* Check if the condition function exists
*/
if (conditionFn) {
/**
* Invoke the condition function with two arguments:
* (1) The value of the condition attribute set on the node e.g.,
* for <mini-island client:visible /> this is an empty string ""
* for <mini-island client:media="(max-width: 400px)" />
* this is the string "(max-width: 400px)"
*
* (2) The node i.e., the island DOM node
*/
const conditionPromise = conditionFn(
conditionAttributesMap[condition],
this
);
/**
* append the promise to the conditions array
*/
conditions.push(conditionPromise);
}
/**
* Await all promise conditions to be resolved before replacing the template nodes
*/
await Promise.all(conditions);
/**
* Retrieve the relevant <template> child elements of the island
*/
const relevantChildTemplates = this.getTemplates();
/**
* Grab the DOM subtree the template holds and replace the template with live content
*/
this.replaceTemplates(relevantChildTemplates);
}
}
}
class Conditions {
/**
* A map of loading conditions to their respective promises
*/
static map = {
idle: Conditions.waitForIdle,
visible: Conditions.waitForVisible,
media: Conditions.waitForMedia,
};
static getConditions(node) {
/**
* The result variable will hold key - value representing condition - attribute value
* e.g., For <mini-island client:visible>
* result should be { visible: "" }
* and for <mini-island client:media="(max-width: 400px)" />
* result should be { media: "(max-width: 400px)" }
*/
let result = {};
/**
* Loop over all keys of the static map i.e., ["idle", "visible", "media"]
*/
for (const condition of Object.keys(Conditions.map)) {
/**
* Check if the node has attribute of form "client:${key}"
*/
if (node.hasAttribute(`client:${condition}`)) {
/**
* If node has attribute...
* save the condition (key) - attribute (value) to the result object
*/
result[condition] = node.getAttribute(`client:${condition}`);
}
}
return result;
}
static hasConditions(node) {
/**
* Using the "getConditions" static class method, retrieve
* a conditions attributes map
*/
const conditionAttributesMap = Conditions.getConditions(node);
/**
* Check the length of the result keys to determine if there are
* any loading conditions on the node
*/
return Object.keys(conditionAttributesMap).length > 0;
}
static waitForIdle() {
const onLoad = new Promise((resolve) => {
/**
* The document.readyState property describes the loading state of the document.
*/
if (document.readyState !== "complete") {
/**
* Set up an event listener for the "load" event.
* The load event is fired when the whole page has loaded, including all dependent resources such as stylesheets, scripts, iframes, and images
*/
window.addEventListener(
"load",
() => {
/**
* resolve this promise once the "load" event is fired
*/
resolve();
},
/**
* This will remove the listener after the first invocation of the "load" event
*/
{ once: true }
);
} else {
resolve();
}
});
/**
* The window.requestIdleCallback() method queues a function to be called during a browser's idle periods. This enables developers to perform background and low priority work on the main event loop
*/
const onIdle = new Promise((resolve) => {
/**
* Check for "requestIdleCallback" support
*/
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
/**
*pass the promise resolve function as the operation to be queued
*/
resolve();
});
} else {
/**
* resolve the promise immediately if requestIdleCallback isn't supported
*/
resolve();
}
});
/**
* waitForIdle will wait for both promises to be resolved i.e., onIdle and onLoad
*/
return Promise.all([onIdle, onLoad]);
}
/**
*
* @param noop - the value of the condition attribute.
* This is named "noop" as it is not relevant in this condition i.e.,
* as per our API, client:visible always has a falsy attribute value e.g.,
* ✅ <mini-island client:visible />
* ❌ <mini-island client:visible={some-value} />
* @param el - the node element.
* This represents our island DOM node passed during hydration
* @returns - a Promise that resolves when "el" is visible
* NB: relies on the Intersection Observer API
*/
static waitForVisible(noop, el) {
/**
* If the Intersection Observer API is not available,
* go ahead and exit immediately.
*/
if (!("IntersectionObserver" in window)) {
return;
}
/**
* Otherwise, set up a new Promise that is resolved when the
* node parameter (our island DOM node) is visible
*/
return new Promise((resolve) => {
let observer = new IntersectionObserver((entries) => {
let [entry] = entries;
/**
* is visible?
*/
if (entry.isIntersecting) {
/**
* remove observer
*/
observer.unobserve(entry.target);
/**
* resolve promise
*/
resolve();
}
});
/**
* set up the observer on the "el" argument
*/
observer.observe(el);
});
}
/**
*
* @param {*} query - the query string passed to the client:media attribute
* @returns Promise that resolves when the document matches the passed CSS media query
*/
static waitForMedia(query) {
/**
* window.matchMedia(query) returns A MediaQueryList object.
* This object stores information on a media query applied to a document and
* one of the properties on this object is "matches" - a boolean for whether
* the document matches the media query or not.
* Create a new simple object of similar form i.e., with a "matches" property
*/
let queryList = {
matches: true,
};
if (query && "matchMedia" in window) {
queryList = window.matchMedia(query);
}
/**
* If matchMedia isn't supported or query is falsy, return immediately
*/
if (queryList.matches) {
return;
}
return new Promise((resolve) => {
/**
* Set a new listener on the queryList object
* and resolve the promise when there's a match
*/
queryList.addListener((e) => {
if (e.matches) {
resolve();
}
});
});
}
}
/**
* Our solution relies heavily on web components. Check that the
* browser supports web components via the 'customElements' property
*/
if ("customElements" in window) {
/**
* Register our custom element on the CustomElementRegistry object using the define method.
*
* NB: The CustomElementRegistry interface provides methods for registering custom elements and querying registered elements.
*
* NB: The arguments to the define method are the name of the custom element (mini-island)
* and the class (MiniIsland) that defines the behaviour of the custom element.
*
* NB: "Island.tagName" below represents the static class property i.e., "static tagName".
*/
window.customElements.define(MiniIsland.tagName, MiniIsland);
} else {
/**
* custom elements not supported, log an error to the console
*/
console.error(
"Island cannot be initiated because Window.customElements is unavailable"
);
}