htmx.org 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.0] - 2022-02-2
4
+
5
+ * The new [`hx-sync`](/attributes/hx-sync) attribute allows you to synchronize multiple element requests on a single
6
+ element using various strategies (e.g. replace)
7
+ * You can also now abort an element making a request by sending it the `htmx:abort` event
8
+ * [Server Sent Events](/extensions/server-sent-events) and [Web Sockets](/extensions/web-sockets) are now available as
9
+ extensions, in addition to the normal core support. In htmx 2.0, the current `hx-sse` and `hx-ws` attributes will be
10
+ moved entirely out to these new extensions. By moving these features to extensions we will be able to add functionality
11
+ to both of them without compromising the core file size of htmx. You are encouraged to move over to the new
12
+ extensions, but `hx-sse` and `hx-ws` will continue to work indefinitely in htmx 1.x.
13
+ * You can now mask out [attribute inheritance](/docs#inheritance) via the [`hx-disinherit`](/attributes/hx-disinherit) attribute.
14
+ * The `HX-Push` header can now have the `false` value, which will prevent a history snapshot from occuring.
15
+ * Many new extensions, with a big thanks to all the contributors!
16
+ * A new [`alpine-morph`](/extensions/alpine-morph) allows you to use Alpine's swapping engine, which preserves Alpine
17
+ * A [restored](/extensions/restored) extension was added that will trigger a `restore` event on all elements in the DOM
18
+ on history restoration.
19
+ * A [loading-states](/extensions/loading-states) extension was added that allows you to easily manage loading states
20
+ while a request is in flight, including disabling elements, and adding and removing CSS classes.
21
+ * The `this` symbol now resolves properly for the [`hx-include`](/attributes/hx-include) and [`hx-indicator`](/attributes/hx-indicator)
22
+ attributes
23
+ * When an object is included via the [`hx-vals`](/attributes/hx-vals) attribute, it will be converted to JSON (rather
24
+ than rendering as the string `[Object object]"`)
25
+ * You can now pass a swap style in to the `htmx.ajax()` function call.
26
+ * Poll events now contain a `target` attribute, allowing you to filter a poll on the element that is polling.
27
+ * Two new Out Of Band-related events were added: `htmx:oobBeforeSwap` & `htmx:oobAfterSwap`
28
+
3
29
  ## [1.6.1] - 2021-11-22
4
30
 
5
31
  * A new `HX-Retarget` header allows you to change the default target of returned content
package/README.md CHANGED
@@ -35,7 +35,7 @@ By removing these arbitrary constraints htmx completes HTML as a
35
35
 
36
36
  ```html
37
37
  <!-- Load from unpkg -->
38
- <script src="https://unpkg.com/htmx.org@1.6.1" ></script>
38
+ <script src="https://unpkg.com/htmx.org@1.7.0" ></script>
39
39
  <!-- have a button POST a click via AJAX -->
40
40
  <button hx-post="/clicked" hx-swap="outerHTML">
41
41
  Click Me
@@ -74,30 +74,35 @@ keep the core htmx code tidy
74
74
 
75
75
  ### hacking guide
76
76
 
77
- to develop htmx locally, you will need to install the development dependencies:
77
+ To develop htmx locally, you will need to install the development dependencies.
78
+ Use node 15 and run:
78
79
 
79
- * `npm install`
80
+ ```
81
+ npm install
82
+ ```
80
83
 
81
- and then run a web server in the root (easiest with python):
84
+ Then, run a web server in the root.
85
+ This is easiest with Python:
82
86
 
83
- * `python3 -m http.server
84
- `
87
+ ```
88
+ python3 -m http.server
89
+ ```
85
90
 
86
- you can then run the test suite by navigating to:
91
+ You can then run the test suite by navigating to:
87
92
 
88
93
  <http://0.0.0.0:8000/test/>
89
94
 
90
- at this point you can modify `/src/htmx.js` to add features, and then add tests in the appropriate area under `/test`
95
+ At this point you can modify `/src/htmx.js` to add features, and then add tests in the appropriate area under `/test`.
91
96
 
92
97
  * `/test/index.html` - the root test page from which all other tests are included
93
- * `/test/attributres` - attribute specific tests
98
+ * `/test/attributes` - attribute specific tests
94
99
  * `/test/core` - core functionality tests
95
- * `/test/core/regressions.js` - regresssion tests
100
+ * `/test/core/regressions.js` - regression tests
96
101
  * `/test/ext` - extension tests
97
102
  * `/test/manual` - manual tests that cannot be automated
98
103
 
99
104
  htmx uses the [mocha](https://mochajs.org/) testing framework, the [chai](https://www.chaijs.com/) assertion framework
100
- and [sinon](https://sinonjs.org/releases/v11.1.1/fake-xhr-and-server/) to mock out AJAX requests. They are all OK.
105
+ and [sinon](https://sinonjs.org/releases/v9/fake-xhr-and-server/) to mock out AJAX requests. They are all OK.
101
106
 
102
107
  ## haiku
103
108
 
@@ -0,0 +1,16 @@
1
+ htmx.defineExtension('alpine-morph', {
2
+ isInlineSwap: function (swapStyle) {
3
+ return swapStyle === 'morph';
4
+ },
5
+ handleSwap: function (swapStyle, target, fragment) {
6
+ if (swapStyle === 'morph') {
7
+ if (fragment.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
8
+ Alpine.morph(target, fragment.firstElementChild);
9
+ return [target];
10
+ } else {
11
+ Alpine.morph(target, fragment.outerHTML);
12
+ return [target];
13
+ }
14
+ }
15
+ }
16
+ });
@@ -0,0 +1,165 @@
1
+ ;(function () {
2
+ let loadingStatesUndoQueue = []
3
+
4
+ function loadingStateContainer(target) {
5
+ return htmx.closest(target, '[data-loading-states]') || document.body
6
+ }
7
+
8
+ function mayProcessUndoCallback(target, callback) {
9
+ if (document.body.contains(target)) {
10
+ callback()
11
+ }
12
+ }
13
+
14
+ function mayProcessLoadingStateByPath(elt, requestPath) {
15
+ const pathElt = htmx.closest(elt, '[data-loading-path]')
16
+ if (!pathElt) {
17
+ return true
18
+ }
19
+
20
+ return pathElt.getAttribute('data-loading-path') === requestPath
21
+ }
22
+
23
+ function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) {
24
+ const delayElt = htmx.closest(sourceElt, '[data-loading-delay]')
25
+ if (delayElt) {
26
+ const delayInMilliseconds =
27
+ delayElt.getAttribute('data-loading-delay') || 200
28
+ const timeout = setTimeout(() => {
29
+ doCallback()
30
+
31
+ loadingStatesUndoQueue.push(() => {
32
+ mayProcessUndoCallback(targetElt, () => undoCallback())
33
+ })
34
+ }, delayInMilliseconds)
35
+
36
+ loadingStatesUndoQueue.push(() => {
37
+ mayProcessUndoCallback(targetElt, () => clearTimeout(timeout))
38
+ })
39
+ } else {
40
+ doCallback()
41
+ loadingStatesUndoQueue.push(() => {
42
+ mayProcessUndoCallback(targetElt, () => undoCallback())
43
+ })
44
+ }
45
+ }
46
+
47
+ function getLoadingStateElts(loadingScope, type, path) {
48
+ return Array.from(htmx.findAll(loadingScope, `[${type}]`)).filter(
49
+ (elt) => mayProcessLoadingStateByPath(elt, path)
50
+ )
51
+ }
52
+
53
+ function getLoadingTarget(elt) {
54
+ if (elt.getAttribute('data-loading-target')) {
55
+ return Array.from(
56
+ htmx.findAll(elt.getAttribute('data-loading-target'))
57
+ )
58
+ }
59
+ return [elt]
60
+ }
61
+
62
+ htmx.defineExtension('loading-states', {
63
+ onEvent: function (name, evt) {
64
+ if (name === 'htmx:beforeRequest') {
65
+ const container = loadingStateContainer(evt.target)
66
+
67
+ const loadingStateTypes = [
68
+ 'data-loading',
69
+ 'data-loading-class',
70
+ 'data-loading-class-remove',
71
+ 'data-loading-disable',
72
+ ]
73
+
74
+ let loadingStateEltsByType = {}
75
+
76
+ loadingStateTypes.forEach((type) => {
77
+ loadingStateEltsByType[type] = getLoadingStateElts(
78
+ container,
79
+ type,
80
+ evt.detail.pathInfo.path
81
+ )
82
+ })
83
+
84
+ loadingStateEltsByType['data-loading'].forEach((sourceElt) => {
85
+ getLoadingTarget(sourceElt).forEach((targetElt) => {
86
+ queueLoadingState(
87
+ sourceElt,
88
+ targetElt,
89
+ () =>
90
+ (targetElt.style.display =
91
+ sourceElt.getAttribute('data-loading') ||
92
+ 'inline-block'),
93
+ () => (targetElt.style.display = 'none')
94
+ )
95
+ })
96
+ })
97
+
98
+ loadingStateEltsByType['data-loading-class'].forEach(
99
+ (sourceElt) => {
100
+ const classNames = sourceElt
101
+ .getAttribute('data-loading-class')
102
+ .split(' ')
103
+
104
+ getLoadingTarget(sourceElt).forEach((targetElt) => {
105
+ queueLoadingState(
106
+ sourceElt,
107
+ targetElt,
108
+ () =>
109
+ classNames.forEach((className) =>
110
+ targetElt.classList.add(className)
111
+ ),
112
+ () =>
113
+ classNames.forEach((className) =>
114
+ targetElt.classList.remove(className)
115
+ )
116
+ )
117
+ })
118
+ }
119
+ )
120
+
121
+ loadingStateEltsByType['data-loading-class-remove'].forEach(
122
+ (sourceElt) => {
123
+ const classNames = sourceElt
124
+ .getAttribute('data-loading-class-remove')
125
+ .split(' ')
126
+
127
+ getLoadingTarget(sourceElt).forEach((targetElt) => {
128
+ queueLoadingState(
129
+ sourceElt,
130
+ targetElt,
131
+ () =>
132
+ classNames.forEach((className) =>
133
+ targetElt.classList.remove(className)
134
+ ),
135
+ () =>
136
+ classNames.forEach((className) =>
137
+ targetElt.classList.add(className)
138
+ )
139
+ )
140
+ })
141
+ }
142
+ )
143
+
144
+ loadingStateEltsByType['data-loading-disable'].forEach(
145
+ (sourceElt) => {
146
+ getLoadingTarget(sourceElt).forEach((targetElt) => {
147
+ queueLoadingState(
148
+ sourceElt,
149
+ targetElt,
150
+ () => (targetElt.disabled = true),
151
+ () => (targetElt.disabled = false)
152
+ )
153
+ })
154
+ }
155
+ )
156
+ }
157
+
158
+ if (name === 'htmx:afterOnLoad') {
159
+ while (loadingStatesUndoQueue.length > 0) {
160
+ loadingStatesUndoQueue.shift()()
161
+ }
162
+ }
163
+ },
164
+ })
165
+ })()
@@ -0,0 +1,15 @@
1
+ htmx.defineExtension('restored', {
2
+ onEvent : function(name, evt) {
3
+ if (name === 'htmx:restored'){
4
+ var restoredElts = evt.detail.document.querySelectorAll(
5
+ "[hx-trigger='restored'],[data-hx-trigger='restored']"
6
+ );
7
+ // need a better way to do this, would prefer to just trigger from evt.detail.elt
8
+ var foundElt = Array.from(restoredElts).find(
9
+ (x) => (x.outerHTML === evt.detail.elt.outerHTML)
10
+ );
11
+ var restoredEvent = evt.detail.triggerEvent(foundElt, 'restored');
12
+ }
13
+ return;
14
+ }
15
+ })
@@ -0,0 +1,318 @@
1
+ /*
2
+ Server Sent Events Extension
3
+ ============================
4
+ This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
5
+
6
+ */
7
+
8
+ (function(){
9
+
10
+ /** @type {import("../htmx").HtmxInternalApi} */
11
+ var api;
12
+
13
+ htmx.defineExtension("sse", {
14
+
15
+ /**
16
+ * Init saves the provided reference to the internal HTMX API.
17
+ *
18
+ * @param {import("../htmx").HtmxInternalApi} api
19
+ * @returns void
20
+ */
21
+ init: function(apiRef) {
22
+ // store a reference to the internal API.
23
+ api = apiRef;
24
+
25
+ // set a function in the public API for creating new EventSource objects
26
+ if (htmx.createEventSource == undefined) {
27
+ htmx.createEventSource = createEventSource;
28
+ }
29
+ },
30
+
31
+ /**
32
+ * onEvent handles all events passed to this extension.
33
+ *
34
+ * @param {string} name
35
+ * @param {Event} evt
36
+ * @returns void
37
+ */
38
+ onEvent: function(name, evt) {
39
+
40
+ switch (name) {
41
+
42
+ // Try to remove remove an EventSource when elements are removed
43
+ case "htmx:beforeCleanupElement":
44
+ var internalData = api.getInternalData(evt.target)
45
+ if (internalData.sseEventSource) {
46
+ internalData.sseEventSource.close();
47
+ }
48
+ return;
49
+
50
+ // Try to create EventSources when elements are processed
51
+ case "htmx:afterProcessNode":
52
+ createEventSourceOnElement(evt.target);
53
+ }
54
+ }
55
+ });
56
+
57
+ ///////////////////////////////////////////////
58
+ // HELPER FUNCTIONS
59
+ ///////////////////////////////////////////////
60
+
61
+
62
+ /**
63
+ * createEventSource is the default method for creating new EventSource objects.
64
+ * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
65
+ *
66
+ * @param {string} url
67
+ * @returns EventSource
68
+ */
69
+ function createEventSource(url) {
70
+ return new EventSource(url, {withCredentials:true});
71
+ }
72
+
73
+ function splitOnWhitespace(trigger) {
74
+ return trigger.trim().split(/\s+/);
75
+ }
76
+
77
+ function getLegacySSEURL(elt) {
78
+ var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
79
+ if (legacySSEValue) {
80
+ var values = splitOnWhitespace(legacySSEValue);
81
+ for (var i = 0; i < values.length; i++) {
82
+ var value = values[i].split(/:(.+)/);
83
+ if (value[0] === "connect") {
84
+ return value[1];
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ function getLegacySSESwaps(elt) {
91
+ var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
92
+ var returnArr = [];
93
+ if (legacySSEValue) {
94
+ var values = splitOnWhitespace(legacySSEValue);
95
+ for (var i = 0; i < values.length; i++) {
96
+ var value = values[i].split(/:(.+)/);
97
+ if (value[0] === "swap") {
98
+ returnArr.push(value[1]);
99
+ }
100
+ }
101
+ }
102
+ return returnArr;
103
+ }
104
+
105
+ /**
106
+ * createEventSourceOnElement creates a new EventSource connection on the provided element.
107
+ * If a usable EventSource already exists, then it is returned. If not, then a new EventSource
108
+ * is created and stored in the element's internalData.
109
+ * @param {HTMLElement} elt
110
+ * @param {number} retryCount
111
+ * @returns {EventSource | null}
112
+ */
113
+ function createEventSourceOnElement(elt, retryCount) {
114
+
115
+ if (elt == null) {
116
+ return null;
117
+ }
118
+
119
+ var internalData = api.getInternalData(elt);
120
+
121
+ // get URL from element's attribute
122
+ var sseURL = api.getAttributeValue(elt, "sse-connect");
123
+
124
+
125
+ if (sseURL == undefined) {
126
+ var legacyURL = getLegacySSEURL(elt)
127
+ if (legacyURL) {
128
+ sseURL = legacyURL;
129
+ } else {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // Connect to the EventSource
135
+ var source = htmx.createEventSource(sseURL);
136
+ internalData.sseEventSource = source;
137
+
138
+ // Create event handlers
139
+ source.onerror = function (err) {
140
+
141
+ // Log an error event
142
+ api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source});
143
+
144
+ // If parent no longer exists in the document, then clean up this EventSource
145
+ if (maybeCloseSSESource(elt)) {
146
+ return;
147
+ }
148
+
149
+ // Otherwise, try to reconnect the EventSource
150
+ if (source.readyState === EventSource.CLOSED) {
151
+ retryCount = retryCount || 0;
152
+ var timeout = Math.random() * (2 ^ retryCount) * 500;
153
+ window.setTimeout(function() {
154
+ createEventSourceOnElement(elt, Math.min(7, retryCount+1));
155
+ }, timeout);
156
+ }
157
+ };
158
+
159
+ // Add message handlers for every `sse-swap` attribute
160
+ queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
161
+
162
+ var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
163
+ if (sseSwapAttr) {
164
+ var sseEventNames = sseSwapAttr.split(",");
165
+ } else {
166
+ var sseEventNames = getLegacySSESwaps(child);
167
+ }
168
+
169
+ for (var i = 0 ; i < sseEventNames.length ; i++) {
170
+ var sseEventName = sseEventNames[i].trim();
171
+ var listener = function(event) {
172
+
173
+ // If the parent is missing then close SSE and remove listener
174
+ if (maybeCloseSSESource(elt)) {
175
+ source.removeEventListener(sseEventName, listener);
176
+ return;
177
+ }
178
+
179
+ // swap the response into the DOM and trigger a notification
180
+ swap(child, event.data);
181
+ api.triggerEvent(elt, "htmx:sseMessage", event);
182
+ };
183
+
184
+ // Register the new listener
185
+ api.getInternalData(elt).sseEventListener = listener;
186
+ source.addEventListener(sseEventName, listener);
187
+ }
188
+ });
189
+
190
+ // Add message handlers for every `hx-trigger="sse:*"` attribute
191
+ queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
192
+
193
+ var sseEventName = api.getAttributeValue(child, "hx-trigger");
194
+ if (sseEventName == null) {
195
+ return;
196
+ }
197
+
198
+ // Only process hx-triggers for events with the "sse:" prefix
199
+ if (sseEventName.slice(0, 4) != "sse:") {
200
+ return;
201
+ }
202
+
203
+ var listener = function(event) {
204
+
205
+ // If parent is missing, then close SSE and remove listener
206
+ if (maybeCloseSSESource(elt)) {
207
+ source.removeEventListener(sseEventName, listener);
208
+ return;
209
+ }
210
+
211
+ // Trigger events to be handled by the rest of htmx
212
+ htmx.trigger(child, sseEventName, event);
213
+ htmx.trigger(child, "htmx:sseMessage", event);
214
+ }
215
+
216
+ // Register the new listener
217
+ api.getInternalData(elt).sseEventListener = listener;
218
+ source.addEventListener(sseEventName.slice(4), listener);
219
+ });
220
+ }
221
+
222
+ /**
223
+ * maybeCloseSSESource confirms that the parent element still exists.
224
+ * If not, then any associated SSE source is closed and the function returns true.
225
+ *
226
+ * @param {HTMLElement} elt
227
+ * @returns boolean
228
+ */
229
+ function maybeCloseSSESource(elt) {
230
+ if (!api.bodyContains(elt)) {
231
+ var source = api.getInternalData(elt).sseEventSource;
232
+ if (source != undefined) {
233
+ source.close();
234
+ // source = null
235
+ return true;
236
+ }
237
+ }
238
+ return false;
239
+ }
240
+
241
+ /**
242
+ * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
243
+ *
244
+ * @param {HTMLElement} elt
245
+ * @param {string} attributeName
246
+ */
247
+ function queryAttributeOnThisOrChildren(elt, attributeName) {
248
+
249
+ var result = [];
250
+
251
+ // If the parent element also contains the requested attribute, then add it to the results too.
252
+ if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) {
253
+ result.push(elt);
254
+ }
255
+
256
+ // Search all child nodes that match the requested attribute
257
+ elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) {
258
+ result.push(node);
259
+ });
260
+
261
+ return result;
262
+ }
263
+
264
+ /**
265
+ * @param {HTMLElement} elt
266
+ * @param {string} content
267
+ */
268
+ function swap(elt, content) {
269
+
270
+ api.withExtensions(elt, function(extension) {
271
+ content = extension.transformResponse(content, null, elt);
272
+ });
273
+
274
+ var swapSpec = api.getSwapSpecification(elt);
275
+ var target = api.getTarget(elt);
276
+ var settleInfo = api.makeSettleInfo(elt);
277
+
278
+ api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
279
+
280
+ settleInfo.elts.forEach(function (elt) {
281
+ if (elt.classList) {
282
+ elt.classList.add(htmx.config.settlingClass);
283
+ }
284
+ api.triggerEvent(elt, 'htmx:beforeSettle');
285
+ });
286
+
287
+ // Handle settle tasks (with delay if requested)
288
+ if (swapSpec.settleDelay > 0) {
289
+ setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
290
+ } else {
291
+ doSettle(settleInfo)();
292
+ }
293
+ }
294
+
295
+ /**
296
+ * doSettle mirrors much of the functionality in htmx that
297
+ * settles elements after their content has been swapped.
298
+ * TODO: this should be published by htmx, and not duplicated here
299
+ * @param {import("../htmx").HtmxSettleInfo} settleInfo
300
+ * @returns () => void
301
+ */
302
+ function doSettle(settleInfo) {
303
+
304
+ return function() {
305
+ settleInfo.tasks.forEach(function (task) {
306
+ task.call();
307
+ });
308
+
309
+ settleInfo.elts.forEach(function (elt) {
310
+ if (elt.classList) {
311
+ elt.classList.remove(htmx.config.settlingClass);
312
+ }
313
+ api.triggerEvent(elt, 'htmx:afterSettle');
314
+ });
315
+ }
316
+ }
317
+
318
+ })();