From d6ea211333d6b77ae1b52c795f61052cf1ff8ee8 Mon Sep 17 00:00:00 2001 From: scriptogre Date: Thu, 30 Oct 2025 16:48:48 +0200 Subject: [PATCH 1/5] add fetch demo environment & update "Click to Edit" example --- www/content/examples/click-to-edit.md | 155 +- www/static/js/demo-fetch.js | 124 ++ www/static/js/htmx.js | 1793 +++++++++++++++++++++ www/templates/demo.html | 3 +- www/templates/shortcodes/demoenv.html | 2 +- www/themes/htmx-theme/static/css/site.css | 3 +- 6 files changed, 1997 insertions(+), 83 deletions(-) create mode 100644 www/static/js/demo-fetch.js create mode 100644 www/static/js/htmx.js diff --git a/www/content/examples/click-to-edit.md b/www/content/examples/click-to-edit.md index 0e81a80c..11671761 100644 --- a/www/content/examples/click-to-edit.md +++ b/www/content/examples/click-to-edit.md @@ -3,102 +3,99 @@ title = "Click to Edit" template = "demo.html" +++ -The click to edit pattern provides a way to offer inline editing of all or part of a record without a page refresh. +This pattern shows how to edit a record in place, without a page refresh. It works by providing two modes that the user can switch between: -* This pattern starts with a UI that shows the details of a contact. The div has a button that will get the editing UI for the contact from `/contact/1/edit` +### 1. View Mode + +In view mode, display the current value(s) with a way to switch to **Edit Mode** (e.g. a button / icon / etc.). ```html -
-
: Joe
-
: Blow
-
: joe@blow.com
- +
``` +_The \ `GET`s the edit form & replaces the parent `
` with it._ -* This returns a form that can be used to edit the contact + +### 2. Edit Mode + +In edit mode, show a form with **Save** & **Cancel** options. ```html -
-
- - -
-
- - -
-
- - -
- - + + + +

Name:

+ + + + + +
``` +_The form `PUT`s the updated value to the server, which returns the updated view mode HTML to replace the form._ -* The form issues a `PUT` back to `/contact/1`, following the usual REST-ful pattern. +### The REST-ful Pattern + +This pattern follows REST conventions: +- `GET /users/1` - Retrieve the current view (**"View Mode"**) +- `GET /users/1/edit` - Retrieve the edit form (**"Edit Mode"**) +- `PUT /users/1` - Update the resource + +The URL represents the resource (`/users/1`), and the HTTP method indicates the action. {{ demoenv() }} diff --git a/www/static/js/demo-fetch.js b/www/static/js/demo-fetch.js new file mode 100644 index 00000000..18a4cddb --- /dev/null +++ b/www/static/js/demo-fetch.js @@ -0,0 +1,124 @@ +//==================================== +// Fetch Mock Server +//==================================== +const routes = []; +const originalFetch = window.fetch; + +window.fetch = async function(url, init = {}) { + url = typeof url === 'string' ? url : url.url; + const method = (init.headers?.['X-HTTP-Method-Override'] || init.method || 'GET').toUpperCase(); + + // Pass through root and absolute URLs + if (url === "/" || url.startsWith("http")) return originalFetch.apply(this, arguments); + + // Find matching route (strip query params for matching) + const urlWithoutQuery = url.split('?')[0]; + const route = routes.find(r => r.method === method && (r.url instanceof RegExp ? r.url.test(urlWithoutQuery) : r.url === urlWithoutQuery)); + if (!route) return originalFetch.apply(this, arguments); + + // Simulate network delay + await new Promise(r => setTimeout(r, 80)); + + // Execute handler + const headers = {}; + const body = route.handler({ url, method, body: init.body, headers: init.headers }, parseParams(method, url, init.body), headers); + + return new Response(body, { status: 200, headers }); +}; + +function parseParams(method, url, body) { + const parse = str => { + const params = {}; + str?.replace(/([^&=]+)=([^&]*)/g, (_, k, v) => params[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' '))); + return params; + }; + + if (method === "GET") return parse(url.split('?')[1]); + if (typeof body === 'string') return parse(body); + if (body instanceof URLSearchParams || body instanceof FormData) { + const params = {}; + for (const [k, v] of body.entries()) if (typeof v === 'string') params[k] = v; + return params; + } + return {}; +} + +//==================================== +// Routing API +//==================================== +function init(path, response) { + onGet(path, response); + const canvas = document.getElementById("demo-canvas"); + if (canvas) { + const content = response(null, {}); + canvas.innerHTML = content; + pushActivityChip("Initial State", "init", `HTML
${escapeHtml(content)}
`); + } +} + +function onGet(path, handler) { routes.push({ method: 'GET', url: path, handler }); } +function onPost(path, handler) { routes.push({ method: 'POST', url: path, handler }); } +function onPut(path, handler) { routes.push({ method: 'PUT', url: path, handler }); } +function onDelete(path, handler) { routes.push({ method: 'DELETE', url: path, handler }); } + +function params(request) { return parseParams(request.method, request.url, request.body); } +function headers(request) { + const hx = {}; + for (const [k, v] of Object.entries(request.headers || {})) if (k.toLowerCase().startsWith('hx-')) hx[k] = v; + return hx; +} + +//==================================== +// Activity Timeline +//==================================== +let requestId = 0; + +// Register event listener - will be called after htmx loads +function setupActivityTracking() { + document.addEventListener("htmx:after:swap", function(evt) { + if (!document.getElementById("request-count")) return; + + const ctx = evt.detail.ctx; + const hxHeaders = {}; + if (ctx.response?.headers) { + ctx.response.headers.forEach((v, k) => { if (k.toLowerCase().startsWith('hx-')) hxHeaders[k] = v; }); + } + + pushActivityChip( + `${ctx.request.method} ${ctx.request.action}`, + `req-${++requestId}`, + ` +
${ctx.request.method} ${ctx.request.action}
+
parameters: ${JSON.stringify(parseParams(ctx.request.method, ctx.request.action, ctx.request.body))}
+
headers: ${JSON.stringify(hxHeaders)}
+
Response
${escapeHtml(ctx.text || '')}
+
` + ); + document.getElementById("request-count").innerText = ": " + requestId; + }); +} + +// Set up tracking when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupActivityTracking); +} else { + setupActivityTracking(); +} + +function showTimelineEntry(id) { + document.querySelectorAll("#demo-current-request > div").forEach(el => el.classList.toggle('hide', el.id !== id)); + document.querySelectorAll("#demo-timeline > li").forEach(el => el.classList.toggle('active', el.id === id + "-link")); +} + +function pushActivityChip(name, id, content) { + if (content.length > 750) content = content.substr(0, 750) + "..."; + document.getElementById("demo-timeline").insertAdjacentHTML("afterbegin", ``); + document.getElementById("demo-current-request").insertAdjacentHTML("afterbegin", `
${content}
`); + showTimelineEntry(id); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/www/static/js/htmx.js b/www/static/js/htmx.js new file mode 100644 index 00000000..c33d78df --- /dev/null +++ b/www/static/js/htmx.js @@ -0,0 +1,1793 @@ +// noinspection ES6ConvertVarToLetConst +var htmx = (() => { + + class RequestQueue { + #currentRequest = null + #requestQueue = [] + shouldIssueRequest(ctx, queueStrategy) { + if (!this.#currentRequest) { + this.#currentRequest = ctx + return true + } else { + // Update ctx.status properly for replaced request contexts + if (queueStrategy === "replace") { + this.#requestQueue = [] + if (this.#currentRequest) { + // TODO standardize on ctx.status + this.#currentRequest.cancelled = true; + this.#currentRequest.abort(); + } + return true + } else if (queueStrategy === "queue all") { + this.#requestQueue.push(ctx) + ctx.status = "queued"; + } else if (queueStrategy === "drop") { + // ignore the request + ctx.status = "dropped"; + } else if (queueStrategy === "queue last") { + this.#requestQueue = [ctx] + ctx.status = "queued"; + } else if (this.#requestQueue.length === 0) { + // default queue first + this.#requestQueue.push(ctx) + ctx.status = "queued"; + } + return false + } + } + nextRequest() { + this.#currentRequest = null + return this.#requestQueue.shift() + } + + abortCurrentRequest() { + this.#currentRequest?.abort?.() + } + + hasMore() { + return this.#requestQueue?.length + } + } + + class Htmx { + + #scriptingAPIMethods = ['timeout']; + __mutationObserver = new MutationObserver((records) => this.__onMutation(records)); + __actionSelector = "[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete]"; + __boostSelector = "a,form"; + __verbs = ["get", "post", "put", "patch", "delete"]; + __hxOnQuery = new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:")]]') + + constructor() { + this.__initHtmxConfig(); + document.addEventListener("mx:process", (evt) => this.process(evt.target)); + this.__initInternals(); + } + + __initInternals() { + document.addEventListener("DOMContentLoaded", () => { + this.__mutationObserver.observe(document.body, {childList: true, subtree: true}); + this.__initHistoryHandling(); + this.process(document.body) + }) + } + + __onMutation(mutations) { + for (let mutation of mutations) { + for (let node of mutation.removedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.__cleanup(node); + } + } + for (let node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.__processScripts(node); + this.process(node); + } + } + } + }; + + // TODO make most of the things like default swap, etc configurable + __initHtmxConfig() { + this.config = { + logAll: false, + viewTransitions: true, + historyEnabled: true, + selfRequestsOnly: true, + reloadOnHistoryNavigation: false, + defaultSwapStyle: "innerHTML", + defaultTimeout: 60000, /* 00 second default timeout */ + streams: { + mode: 'once', + maxRetries: Infinity, + initialDelay: 500, + maxDelay: 30000, + pauseHidden: false + }, + scriptingAPI : this.__initScriptingAPI() + } + let metaConfig = this.find('meta[name="htmx:config"]'); + if (metaConfig) { + let overrides = JSON.parse(metaConfig.content); + // Deep merge nested config objects + for (let key in overrides) { + let val = overrides[key]; + if (val && typeof val === 'object' && !Array.isArray(val) && this.config[key]) { + Object.assign(this.config[key], val); + } else { + this.config[key] = val; + } + } + } + } + + __ignore(elt) { + return elt.closest("[hx-ignore]") != null + } + + __attributeValue(elt, name, defaultVal) { + let inheritName = name + ":inherited"; + if (elt.hasAttribute(name) || elt.hasAttribute(inheritName)) { + return elt.getAttribute(name) || elt.getAttribute(inheritName); + } + let value = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}`)?.getAttribute(inheritName) || defaultVal; + return value + } + + __initScriptingAPI() { + let api = {} + for (let methodName of this.#scriptingAPIMethods) { + api[methodName] = this[methodName].bind(this) + } + return api + } + + __tokenize(str) { + let tokens = [], i = 0; + while (i < str.length) { + let c = str[i]; + if (c === '"' || c === "'") { + let q = c, s = c; + i++; + while (i < str.length) { + c = str[i]; + s += c; + i++; + if (c === '\\' && i < str.length) { + s += str[i]; + i++; + } else if (c === q) break; + } + tokens.push(s); + } else if (/\s/.test(c)) { + while (i < str.length && /\s/.test(str[i])) i++; + } else if (c === ':' || c === ',') { + tokens.push(c); + i++; + } else { + let t = ''; + while (i < str.length && !/[\s"':,]/.test(str[i])) t += str[i++]; + tokens.push(t); + } + } + return tokens; + } + + __parseTriggerSpecs(spec) { + let specs = [] + let currentSpec = null + let tokens = this.__tokenize(spec); + for (let i = 0; i < tokens.length; i++) { + let token = tokens[i]; + if (token === ",") { + currentSpec = null; + } else if (!currentSpec) { + while (token.includes("[") && !token.includes("]") && i + 1 < tokens.length) { + token += tokens[++i]; + } + if (token.includes("[") && !token.includes("]")) { + throw "Unterminated event filter: " + token; + } + currentSpec = {name: token}; + specs.push(currentSpec); + } else if (tokens[i + 1] === ":") { + currentSpec[token] = tokens[i += 2]; + } else { + currentSpec[token] = true; + } + } + + return specs; + } + + __determineMethodAndAction(elt, evt) { + if (this.__isBoosted(elt)) { + return this.__boostedMethodAndAction(elt, evt) + } else { + let method = this.__attributeValue(elt, "hx-method") || "get"; + let action = this.__attributeValue(elt, "hx-action"); + if (!action) { + for (let verb of this.__verbs) { + let verbAttribute = this.__attributeValue(elt, "hx-" + verb); + if (verbAttribute) { + action = verbAttribute; + method = verb; + break; + } + } + } + method = method.toUpperCase() + return {action, method} + } + } + + __boostedMethodAndAction(elt, evt) { + if (elt.matches("a")) { + return {action: elt.getAttribute("href"), method: "GET"} + } else { + let action = evt.submitter?.getAttribute?.("formAction") || elt.getAttribute("action"); + let method = evt.submitter?.getAttribute?.("formMethod") || elt.getAttribute("method") || "GET"; + return {action, method} + } + } + + __initializeElement(elt) { + if (this.__shouldInitialize(elt) && this.__trigger(elt, "htmx:before:init", {}, true)) { + elt.__htmx = {eventHandler: this.__createHtmxEventHandler(elt), requests: []} + elt.setAttribute('data-htmx-powered', 'true'); + this.__initializeTriggers(elt); + this.__initializePreload(elt); + this.__initializeStreamConfig(elt); + this.__initializeAbortListener(elt) + this.__trigger(elt, "htmx:after:init", {}, true) + this.__trigger(elt, "load", {}, false) + } + } + + __createHtmxEventHandler(elt) { + return async (evt) => { + try { + let ctx = this.__createRequestContext(elt, evt); + await this.handleTriggerEvent(ctx); + } catch(e) { + console.error(e) + } + }; + } + + __createRequestContext(sourceElement, sourceEvent) { + let {action, method} = this.__determineMethodAndAction(sourceElement, sourceEvent); + let ctx = { + sourceElement, + sourceEvent, + status: "created", + select: this.__attributeValue(sourceElement, "hx-select"), + selectOOB: this.__attributeValue(sourceElement, "hx-select-oob"), + optimistic: this.__attributeValue(sourceElement, "hx-optimistic"), + target: this.__attributeValue(sourceElement, "hx-target"), + swap: this.__attributeValue(sourceElement, "hx-swap", this.config.defaultSwapStyle), + push: this.__attributeValue(sourceElement, "hx-push-url"), + replace: this.__attributeValue(sourceElement, "hx-replace-url"), + transition: this.config.viewTransitions, + request: { + validate: "true" === this.__attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') ? "true" : "false"), + action, + method, + headers: this.__determineHeaders(sourceElement) + } + }; + + // Apply hx-config overrides + let configAttr = this.__attributeValue(sourceElement, "hx-config"); + if (configAttr) { + let configOverrides = JSON.parse(configAttr); + let requestConfig = ctx.request; + for (let key in configOverrides) { + if (key.startsWith('+')) { + let actualKey = key.substring(1); + if (requestConfig[actualKey] && typeof ctx[actualKey] === 'object') { + Object.assign(ctx[actualKey], configOverrides[key]); + } else { + requestConfig[actualKey] = configOverrides[key]; + } + } else { + requestConfig[key] = configOverrides[key]; + } + } + } + + return ctx; + } + + __determineHeaders(elt) { + let headers = { + "HX-Request": "true", + "Accept": "text/html, text/event-stream" + }; + if (this.__isBoosted(elt)) { + headers["HX-Boosted"] = "true" + } + let headersAttribute = this.__attributeValue(elt, "hx-headers"); + if (headersAttribute) { + Object.assign(headers, JSON.parse(headersAttribute)); + } + return headers; + } + + __resolveTarget(elt, selector) { + if (selector instanceof Element) { + return selector; + } else if (selector === 'this') { + if (elt.hasAttribute("hx-target")) { + return elt; + } else { + return elt.closest("[hx-target\\:inherited='this']") + } + } else if (selector != null) { + return document.querySelector(selector); + } else if (this.__isBoosted(elt)) { + return document.body + } else { + return elt; + } + } + + __isBoosted(elt) { + return elt.__htmx?.boosted; + } + + async handleTriggerEvent(ctx) { + let elt = ctx.sourceElement + let evt = ctx.sourceEvent + if (!elt.isConnected) return + + if (this.__isModifierKeyClick(evt)) return + + if (this.__shouldCancel(evt)) evt.preventDefault() + + // Resolve swap target + ctx.target = this.__resolveTarget(elt, ctx.target); + + // Build request body + let form = elt.form || elt.closest("form") + let body = this.__collectFormData(elt, form, evt.submitter) + this.__handleHxVals(elt, body) + if (ctx.values) { + for (let k in ctx.values) { + body.delete(k); + body.append(k, ctx.values[k]); + } + } + + // Setup abort controller and action + let ac = new AbortController() + let action = ctx.request.action.replace?.(/#.*$/, '') + // TODO - consider how this works with hx-config, move most to __createRequestContext? + Object.assign(ctx.request, { + originalAction: ctx.request.action, + action, + form, + submitter: evt.submitter, + abort: ac.abort.bind(ac), + body, + credentials: "same-origin", + signal: ac.signal, + ...(this.config.selfRequestsOnly && {mode: "same-origin"}) + }) + + if (!this.__trigger(elt, "htmx:config:request", {ctx: ctx})) return + if (!this.__verbs.includes(ctx.request.method.toLowerCase())) return + if (ctx.request.validate && ctx.request.form && !ctx.request.form.reportValidity()) return + + let javascriptContent = this.__extractJavascriptContent(ctx.request.action); + if (!javascriptContent && /GET|DELETE/.test(ctx.request.method)) { + let params = new URLSearchParams(ctx.request.body) + if (params.size) ctx.request.action += (/\?/.test(ctx.request.action) ? "&" : "?") + params + ctx.request.body = null + } else if(this.__attributeValue(elt, "hx-encoding") !== "multipart/form-data") { + ctx.request.body = new URLSearchParams(ctx.request.body) + } + + if (javascriptContent) { + await this.__executeJavaScriptAsync(ctx.sourceElement, {}, javascriptContent, false); + } else { + await this.__issueRequest(ctx); + } + } + + async __issueRequest(ctx) { + let elt = ctx.sourceElement + let syncStrategy = this.__determineSyncStrategy(elt); + let requestQueue = this.__getRequestQueue(elt); + + if (!requestQueue.shouldIssueRequest(ctx, syncStrategy)) return + + ctx.status = "issuing" + this.__initTimeout(ctx); + + let indicatorsSelector = this.__attributeValue(elt, "hx-indicator"); + this.__showIndicators(indicatorsSelector); + let disableSelector = this.__attributeValue(elt, "hx-disable"); + this.__disableElts(disableSelector); + + try { + // Confirm dialog + let confirmVal = this.__attributeValue(elt, 'hx-confirm') + if (confirmVal) { + let js = this.__extractJavascriptContent(confirmVal); + if (js) { + if(!await this.__executeJavaScriptAsync(ctx.elt, {}, js, true)){ + return + } + } else { + if (!window.confirm(confirmVal)) { + return; + } + } + } + + if (!this.__trigger(elt, "htmx:before:request", {ctx})) return; + + // Fetch response (from preload cache or network) + let response; + if (elt.__htmx?.preload && + elt.__htmx.preload.action === ctx.request.action && + Date.now() < elt.__htmx.preload.expiresAt) { + response = await elt.__htmx.preload.prefetch; + delete elt.__htmx.preload; + } else { + if (elt.__htmx) delete elt.__htmx.preload; + this.__insertOptimisticContent(ctx); + response = await fetch(ctx.request.action, ctx.request); + } + + ctx.response = { + raw: response, + status: response.status, + headers: response.headers, + cancelled: false, + } + + if (!this.__trigger(elt, "htmx:after:request", {ctx})) return; + + let isSSE = response.headers.get("Content-Type")?.includes('text/event-stream'); + if (isSSE) { + // SSE response + await this.__handleSSE(ctx, elt, response); + } else { + // HTTP response + ctx.text = await response.text(); + ctx.status = "response received"; + + if (!ctx.response.cancelled) { + this.__handleHistoryUpdate(ctx); + this.__removeOptimisticContent(ctx); + await this.swap(ctx); + this.__handleAnchorScroll(ctx) + ctx.status = "swapped"; + } + } + + } catch (error) { + ctx.status = "error: " + error; + this.__removeOptimisticContent(ctx); + this.__trigger(elt, "htmx:error", {ctx, error}) + } finally { + this.__hideIndicators(indicatorsSelector); + this.__enableElts(disableSelector); + this.__trigger(elt, "htmx:finally:request", {ctx}) + + if (requestQueue.hasMore()) { + setTimeout(() => this.__issueRequest(requestQueue.nextRequest())) + } + } + } + + async __handleSSE(ctx, elt, response) { + const config = elt.__htmx?.streamConfig || {...this.config.streams}; + + const waitForVisible = () => new Promise(r => { + const onVisible = () => !document.hidden && (document.removeEventListener('visibilitychange', onVisible), r()); + document.addEventListener('visibilitychange', onVisible); + }); + + let lastEventId = null, attempt = 0, currentResponse = response; + + while (elt.isConnected) { + // Handle reconnection for subsequent iterations + if (attempt > 0) { + if (config.mode !== 'continuous' || attempt > config.maxRetries) break; + + if (config.pauseHidden && document.hidden) { + await waitForVisible(); + if (!elt.isConnected) break; + } + + const delay = Math.min(config.initialDelay * Math.pow(2, attempt - 1), config.maxDelay); + const reconnect = { attempt, delay, lastEventId, cancelled: false }; + + ctx.status = "reconnecting to stream"; + if (!this.__trigger(elt, "htmx:before:sse:reconnect", { ctx, reconnect }) || reconnect.cancelled) break; + + await new Promise(r => setTimeout(r, reconnect.delay)); + if (!elt.isConnected) break; + + try { + if (lastEventId) (ctx.request.headers = ctx.request.headers || {})['Last-Event-ID'] = lastEventId; + currentResponse = await fetch(ctx.request.action, ctx.request); + } catch (e) { + ctx.status = "stream error"; + this.__trigger(elt, "htmx:error", { ctx, error: e }); + attempt++; + continue; + } + } + + // Core streaming logic + if (!this.__trigger(elt, "htmx:before:sse:stream", { ctx })) break; + ctx.status = "streaming"; + + attempt = 0; // Reset on successful connection + + try { + for await (const sseMessage of this.__parseSSE(currentResponse)) { + if (!elt.isConnected) break; + + if (config.pauseHidden && document.hidden) { + await waitForVisible(); + if (!elt.isConnected) break; + } + + const msg = { data: sseMessage.data, event: sseMessage.event, id: sseMessage.id, cancelled: false }; + if (!this.__trigger(elt, "htmx:before:sse:message", { ctx, message: msg }) || msg.cancelled) continue; + + if (sseMessage.id) lastEventId = sseMessage.id; + + // Trigger custom event if `event:` line is present + if (sseMessage.event) { + this.__trigger(elt, sseMessage.event, { data: sseMessage.data, id: sseMessage.id }); + // Skip swap for custom events + this.__trigger(elt, "htmx:after:sse:message", { ctx, message: msg }); + continue; + } + + ctx.text = sseMessage.data; + ctx.status = "stream message received"; + + if (!ctx.response.cancelled) { + this.__handleHistoryUpdate(ctx); + this.__removeOptimisticContent(ctx); + await this.swap(ctx); + this.__handleAnchorScroll(ctx); + ctx.status = "swapped"; + } + this.__trigger(elt, "htmx:after:sse:message", { ctx, message: msg }); + } + } catch (e) { + ctx.status = "stream error"; + this.__trigger(elt, "htmx:error", { ctx, error: e }); + } + + if (!elt.isConnected) break; + this.__trigger(elt, "htmx:after:sse:stream", { ctx }); + + attempt++; + } + } + + async* __parseSSE(res) { + let r = res.body.getReader(), d = new TextDecoder(), b = '', m = {data:'',event:'',id:'',retry:null}, ls, i, n, f, v; + try { + while (1) { + let {done, value} = await r.read(); + if (done) break; + for (let l of (b += d.decode(value, {stream:1}), ls = b.split('\n'), b = ls.pop()||'', ls)) + !l || l === '\r' ? m.data && (yield m, m = {data:'',event:'',id:'',retry:null}) : + (i = l.indexOf(':')) > 0 && (f = l.slice(0,i), v = l.slice(i+1).trimStart(), + f === 'data' ? m.data += (m.data?'\n':'')+v : + f === 'event' ? m.event = v : + f === 'id' ? m.id = v : + f === 'retry' && (n = parseInt(v,10), !isNaN(n)) ? m.retry = n : 0); + } + } finally { + r.releaseLock(); + } + } + + __initTimeout(ctx) { + let timeoutInterval; + if (ctx.request.timeout) { + timeoutInterval = typeof ctx.request.timeout == "string" ? this.parseInterval(ctx.request.timeout) : ctx.request.timeout; + } else { + timeoutInterval = htmx.config.defaultTimeout; + } + ctx.requestTimeout = setTimeout(() => ctx.abort(), timeoutInterval); + } + + __determineSyncStrategy(elt) { + let syncValue = this.__attributeValue(elt, "hx-sync"); + return syncValue?.split(":")[1] || "queue first"; + } + + __getRequestQueue(elt) { + let syncValue = this.__attributeValue(elt, "hx-sync"); + let syncElt = elt + if (syncValue != null && syncValue !== 'this') { + let selector = syncValue.split(":")[0]; + syncElt = this.__findExt(selector); + } + return syncElt.__htmxRequestQueue ||= new RequestQueue() + } + + __isModifierKeyClick(evt) { + return evt.type === 'click' && (evt.ctrlKey || evt.metaKey || evt.shiftKey) + } + + __shouldCancel(evt) { + let elt = evt.currentTarget + let isSubmit = evt.type === 'submit' && elt?.tagName === 'FORM' + if (isSubmit) return true + + let isClick = evt.type === 'click' && evt.button === 0 + if (!isClick) return false + + let btn = elt?.closest?.('button, input[type="submit"], input[type="image"]') + let form = btn?.form || btn?.closest('form') + let isSubmitButton = btn && !btn.disabled && form && + (btn.type === 'submit' || btn.type === 'image' || (!btn.type && btn.tagName === 'BUTTON')) + if (isSubmitButton) return true + + let link = elt?.closest?.('a') + if (!link || !link.href) return false + + let href = link.getAttribute('href') + let isFragmentOnly = href && href.startsWith('#') && href.length > 1 + return !isFragmentOnly + } + + __initializeTriggers(elt, initialHandler = elt.__htmx.eventHandler) { + let specString = this.__attributeValue(elt, "hx-trigger"); + if (!specString) { + specString = elt.matches("form") ? "submit" : + elt.matches("input:not([type=button]),select,textarea") ? "change" : + "click"; + } + elt.__htmx.triggerSpecs = this.__parseTriggerSpecs(specString) + elt.__htmx.listeners = [] + for (let spec of elt.__htmx.triggerSpecs) { + spec.handler = initialHandler + spec.listeners = [] + spec.values = {} + + let [eventName, filter] = this.__extractFilter(spec.name); + + // should be first so logic is called only when all other filters pass + if (spec.once) { + let original = spec.handler + spec.handler = (evt) => { + original(evt) + for (let listenerInfo of spec.listeners) { + listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler) + } + } + } + + if (eventName === 'intersect' || eventName === "revealed") { + const observerOptions = {} + if (spec.opts['root']) { + observerOptions.root = this.__findExt(elt, spec.opts['root']) + } + if (spec.threshold) { + observerOptions.threshold = parseFloat(spec.threshold) + } + let observer = new IntersectionObserver((entries) => { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + if (entry.isIntersecting) { + setTimeout(()=> { + this.trigger(elt, 'intersect') + }, 20) + break + } + } + }, observerOptions) + observer.observe(elt) + if (eventName === "revealed") { + eventName = 'intersect'; // revealed is handled by intersection observers as well + spec.once = true; + } + } + + if (spec.delay) { + let original = spec.handler + spec.handler = evt => { + clearTimeout(spec.timeout) + spec.timeout = setTimeout(() => original(evt), + this.parseInterval(spec.delay)); + } + } + + if (spec.throttle) { + let original = spec.handler + spec.handler = evt => { + if (spec.throttled) { + spec.throttledEvent = evt + } else { + spec.throttled = true + original(evt); + spec.throttleTimeout = setTimeout(() => { + spec.throttled = false + if (spec.throttledEvent) { + // implement trailing-edge throttling + let throttledEvent = spec.throttledEvent; + spec.throttledEvent = null + spec.handler(throttledEvent); + } + }, this.parseInterval(spec.throttle)) + } + } + } + + if (spec.target) { + let original = spec.handler + spec.handler = evt => { + if (evt.target?.matches?.(spec.target)) { + original(evt) + } + } + } + + if (eventName === "every") { + let interval = Object.keys(spec).find(k => k !== 'name'); + spec.interval = setInterval(() => { + if (elt.isConnected) { + this.__trigger(elt, 'every', {}, false); + } else { + clearInterval(spec.interval) + } + }, this.parseInterval(interval)); + } + + if (filter) { + let original = spec.handler + spec.handler = (evt) => { + let tmp = this.__executeJavaScript(elt, evt, filter); + console.log("1", tmp) + if (tmp) { + original(evt) + } + } + } + + let fromElts = [elt]; + if (spec.from) { + fromElts = this.__findAllExt(spec.from) + } + + if (spec.consume) { + let original = spec.handler + spec.handler = (evt) => { + evt.stopPropagation() + original(evt) + } + } + + if (spec.changed) { + let original = spec.handler + spec.handler = (evt) => { + let trigger = false + for (let fromElt of fromElts) { + if (spec.values[fromElt] !== fromElt.value) { + trigger = true + spec.values[fromElt] = fromElt.value + } + } + if (trigger) { + original(evt) + } + } + } + + for (let fromElt of fromElts) { + let listenerInfo = {fromElt, eventName, handler: spec.handler}; + elt.__htmx.listeners.push(listenerInfo) + spec.listeners.push(listenerInfo) + fromElt.addEventListener(eventName, spec.handler); + } + } + } + + __initializePreload(elt) { + let preloadSpec = this.__attributeValue(elt, "hx-preload"); + if (!preloadSpec) return; + + let specs = this.__parseTriggerSpecs(preloadSpec); + if (specs.length === 0) return; + + let spec = specs[0]; + let eventName = spec.name; + let timeout = spec.timeout ? this.parseInterval(spec.timeout) : 5000; + + let preloadListener = async (evt) => { + // Only preload GET requests + let {method} = this.__determineMethodAndAction(elt, evt); + if (method !== 'GET') return; + + // Skip if already preloading + if (elt.__htmx.preload) return; + + // Create config and build full action URL with params + let ctx = this.__createRequestContext(elt, evt); + let form = elt.form || elt.closest("form"); + let body = this.__collectFormData(elt, form, evt.submitter); + this.__handleHxVals(elt, body); + + let action = ctx.request.action.replace?.(/#.*$/, ''); + let params = new URLSearchParams(body); + if (params.size) action += (/\?/.test(action) ? "&" : "?") + params; + + // Store preload info + elt.__htmx.preload = { + prefetch: fetch(action, ctx.request), + action: action, + expiresAt: Date.now() + timeout + }; + + try { + await elt.__htmx.preload.prefetch; + } catch (error) { + // Clear on error so actual trigger will retry + delete elt.__htmx.preload; + } + }; + elt.addEventListener(eventName, preloadListener); + elt.__htmx.preloadListener = preloadListener; + elt.__htmx.preloadEvent = eventName; + } + + __initializeStreamConfig(elt) { + let streamSpec = this.__attributeValue(elt, 'hx-stream'); + if (!streamSpec) return; + + // Start with global defaults + let streamConfig = {...this.config.streams}; + let tokens = this.__tokenize(streamSpec); + + for (let i = 0; i < tokens.length; i++) { + let token = tokens[i]; + // Main value: once or continuous + if (token === 'once' || token === 'continuous') { + streamConfig.mode = token; + } else if (token === 'pauseHidden') { + streamConfig.pauseHidden = true; + } else if (tokens[i + 1] === ':') { + let key = token, value = tokens[i + 2]; + if (key === 'mode') streamConfig.mode = value; + else if (key === 'maxRetries') streamConfig.maxRetries = parseInt(value); + else if (key === 'initialDelay') streamConfig.initialDelay = this.parseInterval(value); + else if (key === 'maxDelay') streamConfig.maxDelay = this.parseInterval(value); + else if (key === 'pauseHidden') streamConfig.pauseHidden = value === 'true'; + i += 2; + } + } + + if (!elt.__htmx) elt.__htmx = {}; + elt.__htmx.streamConfig = streamConfig; + } + + __extractFilter(str) { + let match = str.match(/^([^\[]*)\[([^\]]*)]/); + if (!match) return [str, null]; + return [match[1], match[2]]; + } + + async __executeJavaScriptAsync(thisArg, obj, code, expression = true) { + let args = {} + Object.assign(args, this.config.scriptingAPI) + Object.assign(args, obj) + let keys = Object.keys(args); + let values = Object.values(args); + let AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + let func = new AsyncFunction(...keys, expression ? `return (${code})` : code); + return await func.call(thisArg, ...values); + } + + __executeJavaScript(thisArg, obj, code, expression = true) { + let args = {} + Object.assign(args, this.config.scriptingAPI) + Object.assign(args, obj) + let keys = Object.keys(args); + let values = Object.values(args); + let func = new Function(...keys, expression ? `return (${code})` : code); + let tmp = func.call(thisArg, ...values); + console.log("1", tmp) + return tmp; + } + + process(elt) { + if (this.__ignore(elt)) return; + if (elt.matches(this.__actionSelector)) { + this.__initializeElement(elt) + } + for (let child of elt.querySelectorAll(this.__actionSelector)) { + this.__initializeElement(child); + } + if (elt.matches(this.__boostSelector)) { + this.__maybeBoost(elt) + } + for (let child of elt.querySelectorAll(this.__boostSelector)) { + this.__maybeBoost(child); + } + this.__handleHxOnAttributes(elt); + let iter = this.__hxOnQuery.evaluate(elt) + let node = null + while (node = iter.iterateNext()) this.__handleHxOnAttributes(node) + + } + + __maybeBoost(elt) { + if (this.__attributeValue(elt, "hx-boost") === "true") { + if (this.__shouldInitialize(elt)) { + elt.__htmx = {eventHandler: this.__createHtmxEventHandler(elt), requests: [], boosted: true} + elt.setAttribute('data-htmx-powered', 'true'); + if (elt.matches('a') && !elt.hasAttribute("target")) { + elt.addEventListener('click', (click) => { + elt.__htmx.eventHandler(click) + }) + } else { + elt.addEventListener('submit', (submit) => { + elt.__htmx.eventHandler(submit) + }) + } + } + } + } + + __shouldInitialize(elt) { + return !elt.__htmx && !this.__ignore(elt); + } + + __cleanup(elt) { + if (elt.__htmx) { + this.__trigger(elt, "htmx:before:cleanup") + if (elt.__htmx.interval) clearInterval(elt.__htmx.interval); + for (let spec of elt.__htmx.triggerSpecs || []) { + if (spec.interval) clearInterval(spec.interval); + if (spec.timeout) clearTimeout(spec.timeout); + } + for (let listenerInfo of elt.__htmx.listeners || []) { + listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler); + } + if (elt.__htmx.preloadListener) { + elt.removeEventListener(elt.__htmx.preloadEvent, elt.__htmx.preloadListener); + } + this.__trigger(elt, "htmx:after:cleanup") + } + for (let child of elt.querySelectorAll('[data-htmx-powered]')) { + this.__cleanup(child); + } + } + + __handlePreservedElements(fragment) { + let preserved = fragment.querySelectorAll?.('[hx-preserve]') || []; + preserved.forEach(newElt => { + let id = newElt.getAttribute('id'); + if (!id) return; + let existingElement = document.getElementById(id); + if (existingElement) { + let pantry = document.getElementById('htmx-preserve-pantry'); + if (!pantry) { + pantry = document.createElement('div'); + pantry.id = 'htmx-preserve-pantry'; + pantry.style.display = 'none'; + document.body.appendChild(pantry); + } + if (pantry.moveBefore) { + pantry.moveBefore(existingElement, null); + } else { + pantry.appendChild(existingElement); + } + } + }); + } + + __restorePreserved() { + let pantry = document.getElementById('htmx-preserve-pantry'); + if (!pantry) return; + for (let preservedElt of [...pantry.children]) { + let existingElement = document.getElementById(preservedElt.id); + if (existingElement) { + existingElement.replaceWith(preservedElt); + } + } + pantry.remove(); + } + + __parseHTML(resp) { + return Document.parseHTMLUnsafe?.(resp) || new DOMParser().parseFromString(resp, 'text/html'); + } + + __makeFragment(text) { + // Replace tags with '); + let responseWithNoHead = response.replace(/]*)?>[\s\S]*?<\/head>/i, ''); + let startTag = responseWithNoHead.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase(); + + let doc, fragment; + if (startTag === 'html') { + doc = this.__parseHTML(response); + fragment = doc.body; + } else if (startTag === 'body') { + doc = this.__parseHTML(responseWithNoHead); + fragment = doc.body; + } else { + doc = this.__parseHTML(`${responseWithNoHead}`); + fragment = doc.body; + } + + return { + fragment, + title: doc.title + }; + } + + __createOOBTask(tasks, elt, oobValue, sourceElement) { + // Handle legacy format: swapStyle:target (only if no spaces, which indicate modifiers) + let target = elt.id ? '#' + CSS.escape(elt.id) : null; + if (oobValue !== 'true' && oobValue && !oobValue.includes(' ')) { + const colonIdx = oobValue.indexOf(':'); + if (colonIdx !== -1) { + target = oobValue.substring(colonIdx + 1); + oobValue = oobValue.substring(0, colonIdx); + } + } + if (oobValue === 'true' || !oobValue) oobValue = 'outerHTML'; + + const swapSpec = this.__parseSwapSpec(oobValue); + if (swapSpec.target) target = swapSpec.target; + + const oobElementClone = elt.cloneNode(true); + let fragment; + if (swapSpec.strip === undefined && swapSpec.style !== 'outerHTML') { + swapSpec.strip = true; + } + if (swapSpec.strip) { + fragment = oobElementClone.content || oobElementClone; + } else { + fragment = document.createDocumentFragment(); + fragment.appendChild(oobElementClone); + } + elt.remove(); + if (!target && !oobValue.includes('target:')) return; + + tasks.push({ + type: 'oob', + fragment, + target, + swapSpec, + async: swapSpec.async === true, + sourceElement + }); + } + + __processOOB(fragment, sourceElement, selectOOB) { + let tasks = []; + + // Process hx-select-oob first (select elements from response) + if (selectOOB) { + selectOOB.split(',').forEach(spec => { + const [selector, ...rest] = spec.split(':'); + const oobValue = rest.length ? rest.join(':') : 'true'; + + fragment.querySelectorAll(selector).forEach(elt => { + this.__createOOBTask(tasks, elt, oobValue, sourceElement); + }); + }); + } + + // Process elements with hx-swap-oob attribute + fragment.querySelectorAll('[hx-swap-oob], [data-hx-swap-oob]').forEach(oobElt => { + const oobValue = oobElt.getAttribute('hx-swap-oob') || oobElt.getAttribute('data-hx-swap-oob'); + oobElt.removeAttribute('hx-swap-oob'); + oobElt.removeAttribute('data-hx-swap-oob'); + + this.__createOOBTask(tasks, oobElt, oobValue, sourceElement); + }); + + return tasks; + } + + __insertNodes(parent, before, fragment) { + if (before) { + before.before(...fragment.childNodes); + } else { + parent.append(...fragment.childNodes); + } + } + + // TODO can we reuse __parseTriggerSpecs here? + __parseSwapSpec(swapStr) { + let tokens = this.__tokenize(swapStr); + let config = {style: tokens[1] === ':' ? this.config.defaultSwapStyle : (tokens[0] || this.config.defaultSwapStyle)}; + let startIdx = tokens[1] === ':' ? 0 : 1; + + for (let i = startIdx; i < tokens.length; i++) { + if (tokens[i + 1] === ':') { + let key = tokens[i], value = tokens[i = i + 2]; + if (key === 'swap') config.swapDelay = this.parseInterval(value); + else if (key === 'settle') config.settleDelay = this.parseInterval(value); + else if (key === 'transition' || key === 'ignoreTitle' || key === 'strip' || key === 'async') config[key] = value === 'true'; + else if (key === 'focus-scroll') config.focusScroll = value === 'true'; + else if (key === 'scroll' || key === 'show') { + let parts = [value]; + while (tokens[i + 1] === ':') { + parts.push(tokens[i + 2]); + i += 2; + } + config[key] = parts.length === 1 ? parts[0] : parts.pop(); + if (parts.length > 1) config[key + 'Target'] = parts.join(':'); + } else if (key === 'target') { + let parts = [value]; + while (i + 1 < tokens.length && tokens[i + 1] !== ':' && tokens[i + 2] !== ':') { + parts.push(tokens[i + 1]); + i++; + } + config[key] = parts.join(' '); + } + } + } + return config; + } + + __processPartials(fragment, sourceElement) { + let tasks = []; + + fragment.querySelectorAll('template[partial]').forEach(partialElt => { + let swapSpec = this.__parseSwapSpec(partialElt.getAttribute('hx-swap') || this.config.defaultSwapStyle); + + tasks.push({ + type: 'partial', + fragment: partialElt.content.cloneNode(true), + target: partialElt.getAttribute('hx-target'), + swapSpec, + async: swapSpec.async === true, + sourceElement + }); + partialElt.remove(); + }); + + return tasks; + } + + async __executeSwapTask(task) { + if (task.swapSpec.swapDelay) { + if (task.async) { + await this.timeout(task.swapSpec.swapDelay); + return this.__performSwap(task); + } else { + return new Promise(resolve => { + setTimeout(() => { + this.__performSwap(task); + resolve(); + }, task.swapSpec.swapDelay); + }); + } + } + return this.__performSwap(task); + } + + __performSwap(task) { + if (typeof task.target === 'string') { + task.target = this.find(task.target); + } + if (!task.target) return; + + this.__handlePreservedElements(task.fragment); + let eventTarget = this.__resolveSwapEventTarget(task); + + if (!this.__trigger(eventTarget, `htmx:before:${task.type}:swap`, {ctx: task})) return; + + const swapTask = () => this.__insertContent(task); + const afterSwap = () => this.__trigger(eventTarget, `htmx:after:${task.type}:swap`, {ctx: task}); + if (task.transition && document["startViewTransition"]) { + return document.startViewTransition(swapTask).finished.then(afterSwap); + } + swapTask(); + afterSwap(); + } + + __insertOptimisticContent(ctx) { + if (!ctx.optimistic) return; + + let sourceElt = document.querySelector(ctx.optimistic); + if (!sourceElt) return; + + let target = ctx.target; + if (!target) return; + + if (typeof target === 'string') { + target = this.find(target); + } + + // Create optimistic div with reset styling + let optimisticDiv = document.createElement('div'); + optimisticDiv.style.cssText = 'all: initial; display: block;'; + optimisticDiv.setAttribute('data-hx-optimistic', 'true'); + optimisticDiv.innerHTML = sourceElt.innerHTML; + + let swapStyle = ctx.swap; + + if (swapStyle === 'innerHTML') { + // Hide children of target + Array.from(target.children).forEach(child => { + child.style.display = 'none'; + child.setAttribute('data-hx-oh', 'true'); + }); + target.appendChild(optimisticDiv); + ctx.optimisticDiv = optimisticDiv; + } else if (['beforebegin', 'afterbegin', 'beforeend', 'afterend'].includes(swapStyle)) { + target.insertAdjacentElement(swapStyle, optimisticDiv); + ctx.optimisticDiv = optimisticDiv; + } else { + // Assume outerHTML-like behavior, Hide target and insert div after it + target.style.display = 'none'; + target.setAttribute('data-hx-oh', 'true'); + target.insertAdjacentElement('afterend', optimisticDiv); + ctx.optimisticDiv = optimisticDiv; + } + } + + __removeOptimisticContent(ctx) { + if (!ctx.optimisticDiv) return; + + // Remove optimistic div + ctx.optimisticDiv.remove(); + + // Unhide any hidden elements + document.querySelectorAll('[data-hx-oh]').forEach(elt => { + elt.style.display = ''; + elt.removeAttribute('data-hx-oh'); + }); + } + + __handleScroll(target, scroll) { + if (scroll === 'top') target.scrollTop = 0; + else if (scroll === 'bottom') target.scrollTop = target.scrollHeight; + } + + __handleAnchorScroll(ctx) { + let anchor = ctx.request.originalAction?.split('#')[1]; + if (anchor) { + document.getElementById(anchor)?.scrollIntoView({block: 'start', behavior: 'auto'}); + } + } + + // TODO - did we punt on other folks inserting scripts? + __processScripts(container) { + container.querySelectorAll('script').forEach(oldScript => { + let newScript = document.createElement('script'); + Array.from(oldScript.attributes).forEach(attr => { + newScript.setAttribute(attr.name, attr.value); + }); + newScript.textContent = oldScript.textContent; + oldScript.replaceWith(newScript); + }); + } + + //============================================================================================ + // Public JS API + //============================================================================================ + + async swap(ctx) { + let {fragment, title} = this.__makeFragment(ctx.text); + let tasks = []; + + // Process OOB and partials + let oobTasks = this.__processOOB(fragment, ctx.sourceElement, ctx.selectOOB); + let partialTasks = this.__processPartials(fragment, ctx.sourceElement); + tasks.push(...oobTasks, ...partialTasks); + + // Create main task if needed + let swapSpec = this.__parseSwapSpec(ctx.swap || this.config.defaultSwapStyle); + if (swapSpec.style === 'delete' || /\S/.test(fragment.innerHTML) || !partialTasks.length) { + let resultFragment = document.createDocumentFragment(); + if (ctx.select) { + let selected = fragment.querySelector(ctx.select); + if (selected) { + if (swapSpec.strip === false) { + resultFragment.append(selected); + } else { + resultFragment.append(...selected.childNodes); + } + } + } else { + resultFragment.append(...fragment.childNodes); + } + + tasks.push({ + type: 'main', + fragment: resultFragment, + target: ctx.target, + swapSpec, + async: swapSpec.async !== false, + sourceElement: ctx.sourceElement, + transition: (ctx.transition !== false) && (swapSpec.transition !== false), + title + }); + } + + if (tasks.length === 0) return; + + // Separate async/sync tasks + let asyncTasks = tasks.filter(t => t.async); + let syncTasks = tasks.filter(t => !t.async); + + // Execute sync tasks immediately + syncTasks.forEach(task => this.__executeSwapTask(task)); + + // Execute async tasks in parallel + if (asyncTasks.length > 0) { + // TODO offer modes where we don't await these? Do them serially? + await Promise.all(asyncTasks.map(task => this.__executeSwapTask(task))); + } + this.__trigger(document, "htmx:after:swap", {ctx}); + } + + __insertContent(swapConfig) { + let swapSpec = swapConfig.swapSpec || swapConfig.modifiers; + this.__handlePreservedElements(swapConfig.fragment); + const target = swapConfig.target, parentNode = target.parentNode; + if (swapSpec.style === 'innerHTML') { + target.replaceChildren(...swapConfig.fragment.childNodes); + } else if (swapSpec.style === 'outerHTML') { + if (parentNode) { + this.__insertNodes(parentNode, target, swapConfig.fragment); + parentNode.removeChild(target); + } + } else if (swapSpec.style === 'beforebegin') { + if (parentNode) { + this.__insertNodes(parentNode, target, swapConfig.fragment); + } + } else if (swapSpec.style === 'afterbegin') { + this.__insertNodes(target, target.firstChild, swapConfig.fragment); + } else if (swapSpec.style === 'beforeend') { + this.__insertNodes(target, null, swapConfig.fragment); + } else if (swapSpec.style === 'afterend') { + if (parentNode) { + this.__insertNodes(parentNode, target.nextSibling, swapConfig.fragment); + } + } else if (swapSpec.style === 'delete') { + if (parentNode) { + parentNode.removeChild(target); + } + return; + } else if (swapSpec.style === 'none') { + return; + } else { + throw new Error(`Unknown swap style: ${swapSpec.style}`); + } + this.__restorePreserved(); + if (swapSpec.scroll) this.__handleScroll(target, swapSpec.scroll); + } + + __resolveSwapEventTarget(task) { + if (task.sourceElement && document.contains(task.sourceElement)) { + return task.sourceElement; + } else if (task.target && document.contains(task.target)) { + return task.target; + } else { + return document; + } + } + + + + __trigger(on, eventName, detail = {}, bubbles = true) { + if (this.config.logAll) { + console.log(eventName, detail, on) + } + return this.trigger(on, eventName, detail, bubbles) + } + + timeout(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + forEvent(event, timeout, on = document) { + return new Promise((resolve, reject) => { + let timeoutId = timeout && setTimeout(() => { + on.removeEventListener(event, handler); + reject(new Error(`Timeout waiting for ${event}`)); + }, timeout); + let handler = (evt) => { + clearTimeout(timeoutId); + on.removeEventListener(event, handler); + resolve(evt); + }; + on.addEventListener(event, handler); + }) + } + + find(selector, on = document) { + return on.querySelector(selector) + } + + findAll(selector, on = document) { + return on.querySelectorAll(selector) + } + + parseInterval(str) { + let m = {ms: 1, s: 1000, m: 60000}; + let [, n, u] = str?.match(/^([\d.]+)(ms|s|m)?$/) || []; + let v = parseFloat(n) * (m[u] || 1); + return isNaN(v) ? undefined : v; + } + + trigger(on, eventName, detail = {}, bubbles = true) { + return on.dispatchEvent(new CustomEvent(eventName, { + detail, cancelable: true, bubbles, composed: true + })) + } + + async waitATick() { + return this.timeout(1) + } + + ajax(verb, path, context) { + // Normalize context to object + if (!context || context instanceof Element || typeof context === 'string') { + context = {target: context}; + } + + let sourceElt = context.source && (typeof context.source === 'string' ? + this.find(context.source) : context.source); + let targetElt = context.target ? + this.__resolveTarget(sourceElt || document.body, context.target) : sourceElt; + + if ((context.target && !targetElt) || (context.source && !sourceElt)) { + return Promise.reject(new Error('Target not found')); + } + + sourceElt = sourceElt || targetElt || document.body; + + let ctx = this.__createRequestContext(sourceElt, context.event || {}); + Object.assign(ctx, context, {target: targetElt}); + if (context.headers) Object.assign(ctx.request.headers, context.headers); + ctx.request.action = path; + ctx.request.method = verb.toUpperCase(); + + return this.handleTriggerEvent(ctx) || Promise.resolve(); + } + + //============================================================================================ + // History Support + //============================================================================================ + + __initHistoryHandling() { + if (!this.config.historyEnabled) return; + // Handle browser back/forward navigation + window.addEventListener('popstate', (event) => { + if (event.state && event.state.htmx) { + this.__restoreHistory(); + } + }); + } + + __pushUrlIntoHistory(path) { + if (!this.config.historyEnabled) return; + history.pushState({htmx: true}, '', path); + this.__trigger(document.body, "htmx:after:push:into:history", {path}); + } + + __replaceUrlInHistory(path) { + if (!this.config.historyEnabled) return; + history.replaceState({htmx: true}, '', path); + this.__trigger(document.body, "htmx:after:replace:into:history", {path}); + } + + __restoreHistory(path) { + path = path || location.pathname + location.search; + if (this.__trigger(document.body, "htmx:before:restore:history", {path, cacheMiss: true})) { + if (htmx.config.reloadOnHistoryNavigation) { + location.reload(); + } else { + this.ajax('GET', path, { + target: 'body', + swap: 'outerHTML', + request: {headers: {'HX-History-Restore-Request': 'true'}} + }); + } + } + } + + __handleHistoryUpdate(ctx) { + let {sourceElement, push, replace, response} = ctx; + let headerPush = response.headers?.get?.('HX-Push') || response.headers?.get?.('HX-Push-Url'); + let headerReplace = response.headers?.get?.('HX-Replace-Url'); + if (headerPush || headerReplace) { + push = headerPush; + replace = headerReplace; + } + + if (!push && !replace && this.__isBoosted(sourceElement)) { + push = 'true'; + } + + let path = push || replace; + if (!path || path === 'false') return; + + if (path === 'true') { + path = ctx.request.originalAction; + } + + let type = push ? 'push' : 'replace'; + + let historyDetail = { + history: {type, path}, + sourceElement, + response + }; + if(!this.__trigger(document.body, "htmx:before:history:update", historyDetail)) return; + if (type === 'push') { + this.__pushUrlIntoHistory(path); + } else { + this.__replaceUrlInHistory(path); + } + this.__trigger(document.body, "htmx:after:history:update", historyDetail); + } + + __handleHxOnAttributes(node) { + for (let attr of node.getAttributeNames()) { + if (attr.startsWith("hx-on:")) { + let evtName = attr.substring(6) + let code = node.getAttribute(attr); + node.addEventListener(evtName, async (evt) => { + try { + await this.__executeJavaScriptAsync(node, {"event": evt}, code, false) + } catch (e) { + console.log(e); + } + }); + } + } + } + + __showIndicators(indicatorsSelector) { + if (indicatorsSelector) { + let indicators = this.__findAllExt(indicatorsSelector); + for (const indicator of indicators) { + indicator.__htmxIndicatorRequests ||= 0 + indicator.__htmxIndicatorRequests++ + indicator.classList.add("htmx-request") + } + } + } + + __hideIndicators(indicatorsSelector) { + if (indicatorsSelector) { + let indicators = this.__findAllExt(indicatorsSelector); + for (const indicator of indicators) { + indicator.__htmxIndicatorRequests ||= 1 + indicator.__htmxIndicatorRequests-- + if (indicator.__htmxIndicatorRequests === 0) { + indicator.classList.remove("htmx-request"); + } + } + } + } + + __disableElts(disabledSelector) { + if (disabledSelector) { + let indicators = this.__findAllExt(disabledSelector); + for (const indicator of indicators) { + indicator.__htmxDisabledRequests ||= 0 + indicator.__htmxDisabledRequests++ + indicator.disabled = true + } + } + } + + __enableElts(disabledSelector) { + if (disabledSelector) { + let indicators = this.__findAllExt(disabledSelector); + for (const indicator of indicators) { + indicator.__htmxDisabledRequests ||= 1 + indicator.__htmxDisabledRequests-- + if (indicator.__htmxDisabledRequests === 0) { + indicator.disabled = false + } + } + } + } + + __collectFormData(elt, form, submitter) { + let formData = new FormData() + let included = new Set() + if (form){ + this.__addInputValues(form, included, formData) + } else if(elt.name) { + formData.append(elt.name, elt.value) + included.add(elt); + } + if (submitter && submitter.name) { + formData.append(submitter.name, submitter.value) + included.add(submitter); + } + let includeSelector = this.__attributeValue(elt, "hx-include"); + if (includeSelector) { + let includeNodes = this.__findAllExt(includeSelector); + for (let node of includeNodes) { + this.__addInputValues(node, included, formData); + } + } + return formData + } + + __addInputValues(elt, included, formData) { + // Get all form elements under this element + let inputs = elt.querySelectorAll('input:not([disabled]), select:not([disabled]), textarea:not([disabled])'); + + for (let input of inputs) { + // Skip elements without a name or already seen + if (!input.name || included.has(input)) continue; + included.add(input); + + if (input.matches('input[type=checkbox], input[type=radio]')) { + // Only add if checked + if (input.checked) { + formData.append(input.name, input.value); + } + } else if (input.matches('input[type=file]')) { + // Add all selected files + for (let file of input.files) { + formData.append(input.name, file); + } + } else if (input.matches('select[multiple]')) { + // Add all selected options + for (let option of input.selectedOptions) { + formData.append(input.name, option.value); + } + } else if (input.matches('select, textarea, input')) { + // Regular inputs, single selects, textareas + formData.append(input.name, input.value); + } + } + } + + __handleHxVals(elt, body) { + let hxValsValue = this.__attributeValue(elt, "hx-vals"); + if (hxValsValue) { + if (!hxValsValue.includes('{')) { + hxValsValue = `{${hxValsValue}}` + } + let obj = JSON.parse(hxValsValue); + for (let key in obj) { + body.append(key, obj[key]) + } + } + } + + __stringHyperscriptStyleSelector(selector) { + const s = selector.trim(); + return s.startsWith('<') && s.endsWith('/>') ? s.slice(1, -2) : s; + } + + __findAllExt(eltOrSelector, maybeSelector, global) { + let [elt, selector] = this.__normalizeElementAndSelector(eltOrSelector, maybeSelector) + if (selector.startsWith('global ')) { + return this.__findAllExt(elt, selector.slice(7), true); + } + const parts = this.__tokenizeExtendedSelector(selector); + const result = [] + const unprocessedParts = [] + for (const part of parts) { + const selector = this.__stringHyperscriptStyleSelector(part) + let item + if (selector.startsWith('closest ')) { + item = elt.closest(selector.slice(8)) + } else if (selector.startsWith('find ')) { + item = this.find(elt, selector.slice(5)) + } else if (selector === 'next' || selector === 'nextElementSibling') { + item = elt.nextElementSibling + } else if (selector.startsWith('next ')) { + item = this.__scanForwardQuery(elt, selector.slice(5), !!global) + } else if (selector === 'previous' || selector === 'previousElementSibling') { + item = elt.previousElementSibling + } else if (selector.startsWith('previous ')) { + item = this.__scanBackwardsQuery(elt, selector.slice(9), !!global) + } else if (selector === 'document') { + item = document + } else if (selector === 'window') { + item = window + } else if (selector === 'body') { + item = document.body + } else if (selector === 'root') { + item = this.__getRootNode(elt, !!global) + } else if (selector === 'host') { + item = (elt.getRootNode()).host + } else { + unprocessedParts.push(selector) + } + + if (item) { + result.push(item) + } + } + + if (unprocessedParts.length > 0) { + const standardSelector = unprocessedParts.join(',') + const rootNode = this.__getRootNode(elt, !!global) + result.push(...rootNode.querySelectorAll(standardSelector)) + } + + return result + } + + __normalizeElementAndSelector(eltOrSelector, selector) { + return typeof eltOrSelector === "string" ? [document, eltOrSelector] : [eltOrSelector, selector]; + } + + __tokenizeExtendedSelector(selector) { + let parts = [], depth = 0, start = 0; + for (let i = 0; i <= selector.length; i++) { + let c = selector[i]; + if (c === '<') depth++; + else if (c === '/' && selector[i + 1] === '>') depth--; + else if ((c === ',' && !depth) || i === selector.length) { + if (i > start) parts.push(selector.substring(start, i)); + start = i + 1; + } + } + return parts; + } + + __scanForwardQuery(start, match, global) { + return this.__scanUntilComparison(this.__getRootNode(start, global).querySelectorAll(match), start, Node.DOCUMENT_POSITION_PRECEDING); + } + + __scanBackwardsQuery(start, match, global) { + const results = [...this.__getRootNode(start, global).querySelectorAll(match)].reverse() + return this.__scanUntilComparison(results, start, Node.DOCUMENT_POSITION_FOLLOWING); + } + + __scanUntilComparison(results, start, comparison) { + for (const elt of results) { + if (elt.compareDocumentPosition(start) === comparison) { + return elt + } + } + } + + __getRootNode(elt, global) { + if (elt.isConnected && elt.getRootNode) { + return elt.getRootNode?.({ composed: global }) + } else { + return document + } + } + + __findExt(eltOrSelector, selector) { + return this.__findAllExt(eltOrSelector, selector)[0] + } + + __extractJavascriptContent(string) { + if(string != null){ + if(string.startsWith("js:")) { + return string.substring(3); + } else if (string.startsWith("javascript:")) { + return string.substring(11); + } + } + } + + __initializeAbortListener(elt) { + elt.addEventListener("htmx:abort", ()=> { + let requestQueue = this.__getRequestQueue(elt); + requestQueue.abortCurrentRequest(); + }) + } + } + + return new Htmx() +})() \ No newline at end of file diff --git a/www/templates/demo.html b/www/templates/demo.html index 5e8839f5..edb560d9 100644 --- a/www/templates/demo.html +++ b/www/templates/demo.html @@ -19,7 +19,6 @@ {% set show_title = true %} {% endif %} {% if show_title %}

{{ page.title | safe }}

{% endif %} - - + {{ page.content | safe }} {% endblock content %} diff --git a/www/templates/shortcodes/demoenv.html b/www/templates/shortcodes/demoenv.html index 4f76f68d..dad8b6b4 100644 --- a/www/templates/shortcodes/demoenv.html +++ b/www/templates/shortcodes/demoenv.html @@ -37,7 +37,7 @@ @media (prefers-color-scheme: dark) { #demo-server-info { - background-color: var(--footerBackground); + background-color: whitesmoke; } } diff --git a/www/themes/htmx-theme/static/css/site.css b/www/themes/htmx-theme/static/css/site.css index e890fd5a..ad1bcef6 100644 --- a/www/themes/htmx-theme/static/css/site.css +++ b/www/themes/htmx-theme/static/css/site.css @@ -79,6 +79,7 @@ pre { padding: 1rem; background: #f5f5f5; border-radius: 4px; + font-size: 0.75rem; /* Big code is overwhelming :( */ } /* Tables */ @@ -121,7 +122,7 @@ blockquote p:last-child { /* Bold code elements */ code { - font-weight: bold; + /*font-weight: bold;*/ /* Bold code is even more overwhelming :( */ color: unset; } From ff8cb2aeb63ae08244b733d45445835ba931938f Mon Sep 17 00:00:00 2001 From: scriptogre Date: Fri, 31 Oct 2025 19:41:14 +0200 Subject: [PATCH 2/5] update debug toolbar - reimplement debug toolbar using _hyperscript - use OS9 aesthetic for debug toolbar --- www/content/examples/active-search.md | 2 +- www/content/examples/bulk-update.md | 2 +- www/content/examples/click-to-edit.md | 85 ++++---- www/content/examples/click-to-load.md | 2 +- www/content/examples/delete-row.md | 2 +- www/content/examples/edit-row.md | 2 +- www/content/examples/infinite-scroll.md | 2 +- www/content/examples/inline-validation.md | 2 +- www/content/examples/keyboard-shortcuts.md | 2 +- www/content/examples/lazy-load.md | 2 +- www/content/examples/modal-bootstrap.md | 2 +- www/content/examples/modal-uikit.md | 2 +- www/content/examples/progress-bar.md | 2 +- www/content/examples/reset-user-input.md | 2 +- www/content/examples/sortable.md | 2 +- www/content/examples/tabs-hateoas.md | 2 +- www/content/examples/tabs-javascript.md | 2 +- www/content/examples/value-select.md | 2 +- www/content/examples/web-components.md | 2 +- www/static/js/demo-fetch.js | 124 ----------- www/static/js/demo.js | 195 ------------------ www/static/js/fetch-mock.js | 55 +++++ www/templates/demo.html | 31 +-- .../shortcodes/demo_environment.html | 153 ++++++++++++++ www/templates/shortcodes/demoenv.html | 69 ------- www/themes/htmx-theme/templates/base.html | 4 +- 26 files changed, 293 insertions(+), 459 deletions(-) delete mode 100644 www/static/js/demo-fetch.js delete mode 100644 www/static/js/demo.js create mode 100644 www/static/js/fetch-mock.js create mode 100644 www/templates/shortcodes/demo_environment.html delete mode 100644 www/templates/shortcodes/demoenv.html diff --git a/www/content/examples/active-search.md b/www/content/examples/active-search.md index c687e42c..7b8e822f 100644 --- a/www/content/examples/active-search.md +++ b/www/content/examples/active-search.md @@ -46,7 +46,7 @@ We can use multiple triggers by separating them with a comma, this way we add 2 Finally, we show an indicator when the search is in flight with the `hx-indicator` attribute. -{{ demoenv() }} +{{ demo_environment() }} + + \ No newline at end of file diff --git a/www/content/examples/click-to-load.md b/www/content/examples/click-to-load.md index f524abbe..7bc176a6 100644 --- a/www/content/examples/click-to-load.md +++ b/www/content/examples/click-to-load.md @@ -21,7 +21,7 @@ the final row: This row contains a button that will replace the entire row with the next page of results (which will contain a button to load the *next* page of results). And so on. -{{ demoenv() }} +{{ demo_environment() }} - {{ page.content | safe }} -{% endblock content %} + +{% if page.extra and page.extra.show_title is defined %} +{% set show_title = page.extra.show_title %} +{% else %} +{% set show_title = true %} +{% endif %} +{% if show_title %}

{{ page.title | safe }}

{% endif %} +{{ page.content | safe }} + +{% endblock content %} \ No newline at end of file diff --git a/www/templates/shortcodes/demo_environment.html b/www/templates/shortcodes/demo_environment.html new file mode 100644 index 00000000..b127f117 --- /dev/null +++ b/www/templates/shortcodes/demo_environment.html @@ -0,0 +1,153 @@ + + + + + + +{# ======================================================================== #} +{# DEMO SECTION #} +{# ======================================================================== #} + +

+ Demo +

+ +
+ +
+ + +{# ======================================================================== #} +{# DEBUG TOOLBAR: Timeline + Request Details #} +{# ======================================================================== #} + +
+ + {# Header (click to expand/collapse) #} + + + {# Content #} +
+ + {# Timeline: List of htmx requests #} +
    + + +
+ + {# Current Request: Parameters, headers, response #} +
+ + + + + + +
+
+
\ No newline at end of file diff --git a/www/templates/shortcodes/demoenv.html b/www/templates/shortcodes/demoenv.html deleted file mode 100644 index dad8b6b4..00000000 --- a/www/templates/shortcodes/demoenv.html +++ /dev/null @@ -1,69 +0,0 @@ - - -
-
Server Requests ↑ Show
-
-
-
    -
-
-
-
-
-
- -

🔗Demo

-
-
diff --git a/www/themes/htmx-theme/templates/base.html b/www/themes/htmx-theme/templates/base.html index ee282fd0..acdd638b 100644 --- a/www/themes/htmx-theme/templates/base.html +++ b/www/themes/htmx-theme/templates/base.html @@ -24,8 +24,8 @@ - - + + From 00bbfd8aaedc1dd977c5c5d10ac7a08c971e13da Mon Sep 17 00:00:00 2001 From: scriptogre Date: Fri, 31 Oct 2025 19:56:40 +0200 Subject: [PATCH 3/5] rename examples to patterns --- www/content/_index.md | 2 +- www/content/attributes/hx-confirm.md | 2 +- www/content/docs.md | 22 +++---- .../essays/10-tips-for-SSR-HDA-apps.md | 4 +- .../essays/a-response-to-rich-harris.md | 8 +-- .../essays/hypermedia-driven-applications.md | 2 +- .../essays/hypermedia-friendly-scripting.md | 2 +- www/content/essays/mvc.md | 2 +- .../essays/webcomponents-work-great.md | 2 +- www/content/essays/when-to-use-hypermedia.md | 14 ++--- www/content/{examples => patterns}/_index.md | 60 +++++++++---------- .../{examples => patterns}/active-search.md | 0 .../{examples => patterns}/animations.md | 2 +- .../{examples => patterns}/async-auth.md | 0 .../{examples => patterns}/bulk-update.md | 0 .../{examples => patterns}/click-to-edit.md | 0 .../{examples => patterns}/click-to-load.md | 0 www/content/{examples => patterns}/confirm.md | 0 .../{examples => patterns}/delete-row.md | 0 www/content/{examples => patterns}/dialogs.md | 0 .../{examples => patterns}/edit-row.md | 0 .../file-upload-input.md | 0 .../{examples => patterns}/file-upload.md | 0 .../{examples => patterns}/infinite-scroll.md | 0 .../inline-validation.md | 0 .../keyboard-shortcuts.md | 0 .../{examples => patterns}/lazy-load.md | 0 .../{examples => patterns}/modal-bootstrap.md | 0 .../{examples => patterns}/modal-custom.md | 2 +- .../{examples => patterns}/modal-uikit.md | 0 .../move-before/_index.md | 0 .../move-before/details.md | 0 .../{examples => patterns}/progress-bar.md | 0 .../reset-user-input.md | 0 .../{examples => patterns}/sortable.md | 0 .../{examples => patterns}/tabs-hateoas.md | 2 +- .../{examples => patterns}/tabs-javascript.md | 2 +- .../update-other-content.md | 0 .../{examples => patterns}/value-select.md | 0 .../{examples => patterns}/web-components.md | 0 .../2020-11-24-htmx-1.0.0-is-released.md | 2 +- .../2020-5-15-kutty-0.0.1-is-released.md | 2 +- .../posts/2021-1-6-htmx-1.1.0-is-released.md | 4 +- .../2024-06-17-htmx-2.0.0-is-released.md | 2 +- www/templates/demo.html | 2 +- www/themes/htmx-theme/templates/base.html | 6 +- 46 files changed, 73 insertions(+), 73 deletions(-) rename www/content/{examples => patterns}/_index.md (75%) rename www/content/{examples => patterns}/active-search.md (100%) rename www/content/{examples => patterns}/animations.md (99%) rename www/content/{examples => patterns}/async-auth.md (100%) rename www/content/{examples => patterns}/bulk-update.md (100%) rename www/content/{examples => patterns}/click-to-edit.md (100%) rename www/content/{examples => patterns}/click-to-load.md (100%) rename www/content/{examples => patterns}/confirm.md (100%) rename www/content/{examples => patterns}/delete-row.md (100%) rename www/content/{examples => patterns}/dialogs.md (100%) rename www/content/{examples => patterns}/edit-row.md (100%) rename www/content/{examples => patterns}/file-upload-input.md (100%) rename www/content/{examples => patterns}/file-upload.md (100%) rename www/content/{examples => patterns}/infinite-scroll.md (100%) rename www/content/{examples => patterns}/inline-validation.md (100%) rename www/content/{examples => patterns}/keyboard-shortcuts.md (100%) rename www/content/{examples => patterns}/lazy-load.md (100%) rename www/content/{examples => patterns}/modal-bootstrap.md (100%) rename www/content/{examples => patterns}/modal-custom.md (98%) rename www/content/{examples => patterns}/modal-uikit.md (100%) rename www/content/{examples => patterns}/move-before/_index.md (100%) rename www/content/{examples => patterns}/move-before/details.md (100%) rename www/content/{examples => patterns}/progress-bar.md (100%) rename www/content/{examples => patterns}/reset-user-input.md (100%) rename www/content/{examples => patterns}/sortable.md (100%) rename www/content/{examples => patterns}/tabs-hateoas.md (99%) rename www/content/{examples => patterns}/tabs-javascript.md (98%) rename www/content/{examples => patterns}/update-other-content.md (100%) rename www/content/{examples => patterns}/value-select.md (100%) rename www/content/{examples => patterns}/web-components.md (100%) diff --git a/www/content/_index.md b/www/content/_index.md index 39715343..d6b8cc5d 100644 --- a/www/content/_index.md +++ b/www/content/_index.md @@ -121,7 +121,7 @@ if(window.location.search=="?ads=true") { htmx gives you access to [AJAX](@/docs.md#ajax), [CSS Transitions](@/docs.md#css_transitions), [WebSockets](@/docs.md) and [Server Sent Events](@/docs.md) directly in HTML, using [attributes](@/reference.md#attributes), so you can build -[modern user interfaces](@/examples/_index.md) with the [simplicity](https://en.wikipedia.org/wiki/HATEOAS) and +[modern user interfaces](@/patterns/_index.md) with the [simplicity](https://en.wikipedia.org/wiki/HATEOAS) and [power](https://www.ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm) of hypertext htmx is small ([~16k min.gz'd](https://cdn.jsdelivr.net/npm/htmx.org/dist/)), diff --git a/www/content/attributes/hx-confirm.md b/www/content/attributes/hx-confirm.md index b03c0246..330bd29e 100644 --- a/www/content/attributes/hx-confirm.md +++ b/www/content/attributes/hx-confirm.md @@ -28,5 +28,5 @@ The event triggered by `hx-confirm` contains additional properties in its `detai ## Notes * `hx-confirm` is inherited and can be placed on a parent element -* `hx-confirm` uses the browser's `window.confirm` by default. You can customize this behavior as shown [in this example](@/examples/confirm.md). +* `hx-confirm` uses the browser's `window.confirm` by default. You can customize this behavior as shown [in this example](@/patterns/confirm.md). * a boolean `skipConfirmation` can be passed to the `issueRequest` callback; if true (defaults to false), the `window.confirm` will not be called and the AJAX request is issued directly diff --git a/www/content/docs.md b/www/content/docs.md index ccb1dc94..77a55fb0 100644 --- a/www/content/docs.md +++ b/www/content/docs.md @@ -125,7 +125,7 @@ In this manner, htmx follows the [original web programming model](https://www.ic of the web, using [Hypertext As The Engine Of Application State](https://en.wikipedia.org/wiki/HATEOAS). While this may seem a little academic (and the ideas are interesting!) it turns out that this small extension to HTML -enables developers to create much more [sophisticated user experiences](@/examples/_index.md) using HTML. +enables developers to create much more [sophisticated user experiences](@/patterns/_index.md) using HTML. ## 2.x to 4.x Migration Guide @@ -292,7 +292,7 @@ issuing the request. Unlike `delay` if a new event occurs before the time limit so the request will trigger at the end of the time period. * `from:` - listen for the event on a different element. This can be used for things like keyboard shortcuts. Note that this CSS selector is not re-evaluated if the page changes. -You can use these attributes to implement many common UX patterns, such as [Active Search](@/examples/active-search.md): +You can use these attributes to implement many common UX patterns, such as [Active Search](@/patterns/active-search.md): ```html more docs on this

-You can see a view transition example on the [Animation Examples](/examples/animations#view-transitions) page. +You can see a view transition example on the [Animation Patterns](/patterns/animations#view-transitions) page. #### Swap Options @@ -683,7 +683,7 @@ in the request. Note that depending on your server-side technology, you may have to handle requests with this type of body content very differently. -See the [examples section](@/examples/_index.md) for more advanced form patterns, including [progress bars](@/examples/file-upload.md) and [error handling](@/examples/file-upload-input.md). +See the [patterns section](@/patterns/_index.md) for more advanced form patterns, including [progress bars](@/patterns/file-upload.md) and [error handling](@/patterns/file-upload-input.md). ### Confirming Requests {#confirming} @@ -784,7 +784,7 @@ a wider audience to use your site's functionality. Other htmx patterns can be adapted to achieve progressive enhancement as well, but they will require more thought. -Consider the [active search](@/examples/active-search.md) example. As it is written, it will not degrade gracefully: +Consider the [active search](@/patterns/active-search.md) example. As it is written, it will not degrade gracefully: someone who does not have javascript enabled will not be able to use this feature. This is done for simplicity’s sake, to keep the example as brief as possible. @@ -1109,7 +1109,7 @@ the [`hx-validate`](@/attributes/hx-validate.md) attribute to "true". Htmx allows you to use [CSS transitions](#css_transitions) in many situations using only HTML and CSS. -Please see the [Animation Guide](@/examples/animations.md) for more details on the options available. +Please see the [Animation Guide](@/patterns/animations.md) for more details on the options available. ## Extensions @@ -1333,7 +1333,7 @@ Here is an example that adds a parameter to an htmx request Here the `example` parameter is added to the `POST` request before it is issued, with the value 'Hello Scripting!'. -Another use case is to [reset user input](@/examples/reset-user-input.md) on successful requests using the `htmx:after:swap` +Another use case is to [reset user input](@/patterns/reset-user-input.md) on successful requests using the `htmx:after:swap` event: ```html @@ -1349,7 +1349,7 @@ Htmx integrates well with third party libraries. If the library fires events on the DOM, you can use those events to trigger requests from htmx. -A good example of this is the [SortableJS demo](@/examples/sortable.md): +A good example of this is the [SortableJS demo](@/patterns/sortable.md): ```html
@@ -1384,7 +1384,7 @@ This will ensure that as new content is added to the DOM by htmx, sortable eleme #### Web Components {#web-components} -Please see the [Web Components Examples](@/examples/web-components.md) page for examples on how to integrate htmx +Please see the [Web Components Pattern](@/patterns/web-components.md) page for examples on how to integrate htmx with web components. ## Caching @@ -1619,4 +1619,4 @@ And that's it! Have fun with htmx! -You can accomplish [quite a bit](@/examples/_index.md) without writing a lot of code! +You can accomplish [quite a bit](@/patterns/_index.md) without writing a lot of code! diff --git a/www/content/essays/10-tips-for-SSR-HDA-apps.md b/www/content/essays/10-tips-for-SSR-HDA-apps.md index 58ace2ea..7ce3b6f5 100644 --- a/www/content/essays/10-tips-for-SSR-HDA-apps.md +++ b/www/content/essays/10-tips-for-SSR-HDA-apps.md @@ -128,7 +128,7 @@ But this experience stinks compared to what people are used to: drag-and-drop. In cases like this, it is perfectly fine to use a front-end heavy approach as an "Island of Interactivity". -Consider the [SortableJS](@/examples/sortable.md) example. Here you have a sophisticated area of interactivity that allows for +Consider the [SortableJS](@/patterns/sortable.md) example. Here you have a sophisticated area of interactivity that allows for drag-and-drop, and that integrates with htmx and the broader hypermedia-driven application via events. This is an excellent way to encapsulate richer UX within an HDA. @@ -153,7 +153,7 @@ Finally, do not be dogmatic about using hypermedia. At the end of the day, it i [strengths & weaknesses](@/essays/when-to-use-hypermedia.md). If a particular part of an app, or if an entire app, demands something more interactive than what hypermedia can deliver, then go with a technology that can. -Just be familiar with [what hypermedia can do](@/examples/_index.md), so you can make that decision as an informed +Just be familiar with [what hypermedia can do](@/patterns/_index.md), so you can make that decision as an informed developer. ## Conclusion diff --git a/www/content/essays/a-response-to-rich-harris.md b/www/content/essays/a-response-to-rich-harris.md index ec36c76a..0de1cec0 100644 --- a/www/content/essays/a-response-to-rich-harris.md +++ b/www/content/essays/a-response-to-rich-harris.md @@ -69,13 +69,13 @@ preserve a particular piece of content between requests. In the presence of infinite scroll behavior (presumably implemented via javascript of some sort) the back button will not work properly with an MPA. I would note that the presence of infinite scroll calls into question the term MPA, which would traditionally use paging instead of an infinite scroll. -That said, [infinite scroll](@/examples/infinite-scroll.md) can be achieved quite easily using htmx, in a hypermedia-oriented and obvious manner. When combined with the [`hx-push-url`](@/attributes/hx-push-url.md) attribute, history and the back button works properly with very little effort by the developer, all with nice Copy-and-Pasteable URLs, sometimes referred to as "Deep Links" by people in the SPA community. +That said, [infinite scroll](@/patterns/infinite-scroll.md) can be achieved quite easily using htmx, in a hypermedia-oriented and obvious manner. When combined with the [`hx-push-url`](@/attributes/hx-push-url.md) attribute, history and the back button works properly with very little effort by the developer, all with nice Copy-and-Pasteable URLs, sometimes referred to as "Deep Links" by people in the SPA community. ### "What about Nice Navigation Transitions?" Nice transitions are, well, nice. We think that designers tend to over-estimate their contribution to application usability, however. Yes, the demo sizzles, but on the 20th click users often just want the UI to get on with it. -That being said, htmx supports using [standard CSS transitions](@/examples/animations.md) to make animations possible. Obviously there is a limit to what you can achieve with these pure CSS techniques, but we believe this can give you the 80 of an 80/20 situation. (Or, perhaps, the 95 of a 95/5 situation.) +That being said, htmx supports using [standard CSS transitions](@/patterns/animations.md) to make animations possible. Obviously there is a limit to what you can achieve with these pure CSS techniques, but we believe this can give you the 80 of an 80/20 situation. (Or, perhaps, the 95 of a 95/5 situation.) ### "Multipage Apps Load Javascript Libraries Every Request" @@ -95,7 +95,7 @@ Of course the problem with latency issues is that they can make an app feel lagg GitHub does, indeed, have UI bugs. However, none of them are particularly difficult to solve. -htmx offers multiple ways to [update content beyond the target element](@/examples/update-other-content.md), all of them quite easy and any of which would work to solve the UI consistency issues Mr. Harris points out. +htmx offers multiple ways to [update content beyond the target element](@/patterns/update-other-content.md), all of them quite easy and any of which would work to solve the UI consistency issues Mr. Harris points out. Contrast the GitHub UI issues with the Instagram UI issues Mr. Harris pointed out earlier: the Instagram issues would require far more sophisticated engineering work to resolve. @@ -136,7 +136,7 @@ AJAX moved to JSON as a data serialization format and largely ([and correctly](@ abandoned the hypermedia concept. This abandonment of The Hypermedia Approach was driven by the admitted usability issues with vanilla MPAs. -It turns out, however, that those usability issues often *can* [be addressed](@/examples/_index.md) using The Hypermedia Approach: +It turns out, however, that those usability issues often *can* [be addressed](@/patterns/_index.md) using The Hypermedia Approach: rather than *abandoning* Hypermedia for RPC, what we needed then and what we need today is a *more powerful* Hypermedia. This is exactly what htmx gives you. diff --git a/www/content/essays/hypermedia-driven-applications.md b/www/content/essays/hypermedia-driven-applications.md index 707e427a..f466ab1a 100644 --- a/www/content/essays/hypermedia-driven-applications.md +++ b/www/content/essays/hypermedia-driven-applications.md @@ -46,7 +46,7 @@ most SPAs abandon HATEOAS in favor of a client-side model and data (rather than ## An Example HDA fragment -Consider the htmx [Active Search](@/examples/active-search.md) example: +Consider the htmx [Active Search](@/patterns/active-search.md) example: ```html

diff --git a/www/content/essays/hypermedia-friendly-scripting.md b/www/content/essays/hypermedia-friendly-scripting.md index b25c3cce..51b570ce 100644 --- a/www/content/essays/hypermedia-friendly-scripting.md +++ b/www/content/essays/hypermedia-friendly-scripting.md @@ -109,7 +109,7 @@ A JavaScript-based component that triggers events allows for hypermedia-oriented to listen for those events and trigger hypermedia exchanges. This, in turn, makes any JavaScript library a potential _hypermedia control_, able to drive the Hypermedia-Driven Application via user-selected actions. -A good example of this is the [Sortable.js](@/examples/sortable.md) example, in which htmx listens for +A good example of this is the [Sortable.js](@/patterns/sortable.md) example, in which htmx listens for the `end` event triggered by Sortable.js: ```html diff --git a/www/content/essays/mvc.md b/www/content/essays/mvc.md index cf1b9ed4..ceb89d3e 100644 --- a/www/content/essays/mvc.md +++ b/www/content/essays/mvc.md @@ -154,7 +154,7 @@ Let's consider another change: we want to add a graph of the number of contacts template in our HTML-based web application. It turns out that this graph is expensive to compute. We do not want to block the rendering of the `index.html` template on the graph generation, so we will use the -[Lazy Loading](@/examples/lazy-load.md) pattern for it instead. To do this, we need to create a new endpoint, `/graph`, +[Lazy Loading](@/patterns/lazy-load.md) pattern for it instead. To do this, we need to create a new endpoint, `/graph`, that returns the HTML for that lazily loaded content: ```python diff --git a/www/content/essays/webcomponents-work-great.md b/www/content/essays/webcomponents-work-great.md index 7427aee7..a2025b14 100644 --- a/www/content/essays/webcomponents-work-great.md +++ b/www/content/essays/webcomponents-work-great.md @@ -161,7 +161,7 @@ The default htmx swap style is to just set [`.innerHTML`](https://developer.mozi That's not to say that htmx doesn't have to accommodate weird Web Component edge cases. Our community member and resident WC expert [Katrina Scialdone](https://unmodernweb.com/) merged [Shadow DOM support for htmx 2.0](https://github.com/bigskysoftware/htmx/pull/2075), which lets htmx process the implementation details of a Web Component, and supporting that is [occasionally](https://github.com/bigskysoftware/htmx/pull/2846) [frustrating](https://github.com/bigskysoftware/htmx/pull/2866). -But being able to work with both the [Shadow DOM](@/examples/web-components.md) and the ["Light DOM"](https://meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/) is a nice feature for htmx, and it carries a relatively minimal support burden because htmx just isn't doing all that much. +But being able to work with both the [Shadow DOM](@/patterns/web-components.md) and the ["Light DOM"](https://meyerweb.com/eric/thoughts/2023/11/01/blinded-by-the-light-dom/) is a nice feature for htmx, and it carries a relatively minimal support burden because htmx just isn't doing all that much. ## Bringing Behavior Back to the HTML diff --git a/www/content/essays/when-to-use-hypermedia.md b/www/content/essays/when-to-use-hypermedia.md index b4503ba8..a83da6e7 100644 --- a/www/content/essays/when-to-use-hypermedia.md +++ b/www/content/essays/when-to-use-hypermedia.md @@ -75,8 +75,8 @@ Another area where hypermedia has a long track-record of success is [CRUD](https web applications, in the [Ruby on Rails](https://rubyonrails.org/) style. If your main application mechanic is showing forms and saving the forms into a database, hypermedia can work very well. -And, with htmx, it can also be [very smooth](@/examples/click-to-edit.md), and not just constrained -to the simple [list view/detail view](@/examples/edit-row.md) approach many server side applications take. +And, with htmx, it can also be [very smooth](@/patterns/click-to-edit.md), and not just constrained +to the simple [list view/detail view](@/patterns/edit-row.md) approach many server side applications take. ### _...If your UI is "nested", with updates mostly taking place within well-defined blocks_ @@ -89,7 +89,7 @@ when you closed an issue on GitHub, the issue count on the tab did not update pr "Ah ha!", exclaims the SPA enthusiast, "See, even GitHub can't get this right!" Well, GitHub has fixed the issue, but it does demonstrate a problem with the hypermedia approach: how do you update -disjoint parts of the UI cleanly? htmx offers [a few techniques for making this work](@/examples/update-other-content.md), +disjoint parts of the UI cleanly? htmx offers [a few techniques for making this work](@/patterns/update-other-content.md), and Contexte, in their talk, discuss handling this situation very cleanly, using the event approach. But, let us grant that this is an area where the hypermedia approach can get into trouble. To avoid this problem, one @@ -128,7 +128,7 @@ our API to satisfy the new requirements. This is a [unique aspect](@/essays/hat hypermedia, and we [discuss it in more detail here](@/essays/hypermedia-apis-vs-data-apis.md). Of course, there may be UI requirements that do not allow for grouping of dependent element in this manner and, if -the techniques [mentioned above](@/examples/update-other-content.md) aren't satisfactory, then it may be +the techniques [mentioned above](@/patterns/update-other-content.md) aren't satisfactory, then it may be time to consider an alternative approach. ### _...If you need "deep links" & good first-render performance_ @@ -152,8 +152,8 @@ Particularly difficult for hypermedia to handle is when these dependencies are d that cannot be determined at server-side render-time. A good example of this is something like a spreadsheet: a user can enter an arbitrary function into a cell and introduce all sorts of dependencies on the screen, on the fly. -(Note, however, that for many applications, the ["editable row"](@/examples/edit-row.md) pattern is an -acceptable alternative to more general spreadsheet-like behavior, and this pattern does play well with hypermedia by +(Note, however, that for many applications, the ["editable row"](@/patterns/edit-row.md) pattern is an +acceptable alternative to more general spreadsheet-like behavior, and this pattern does play well with hypermedia by isolating edits within a bounded area.) ### _...If you require offline functionality_ @@ -180,7 +180,7 @@ style! We should note, however, that it is typically easier to embed SPA components _within_ a larger hypermedia architecture, than vice-versa. Isolated client-side components can communicate with a broader hypermedia application via [events](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events), in the manner demonstrated -in the [drag-and-drop Sortable.js + htmx](@/examples/sortable.md) example. +in the [drag-and-drop Sortable.js + htmx](@/patterns/sortable.md) example. ### _...If you want integrated copy & paste components_ diff --git a/www/content/examples/_index.md b/www/content/patterns/_index.md similarity index 75% rename from www/content/examples/_index.md rename to www/content/patterns/_index.md index bc98a29b..a06fa513 100644 --- a/www/content/examples/_index.md +++ b/www/content/patterns/_index.md @@ -1,5 +1,5 @@ +++ -title = "Examples" +title = "Patterns" insert_anchor_links = "heading" +++ @@ -8,7 +8,7 @@ insert_anchor_links = "heading" A list of [GitHub repositories showing examples of integration](@/server-examples.md) with a wide variety of server-side languages and platforms, including JavaScript, Python, Java, and many others. -## UI Examples +## UI Patterns Below are a set of UX patterns implemented in htmx with minimal HTML and styling. @@ -16,34 +16,34 @@ You can copy and paste them and then adjust them for your needs. | Pattern | Description | |-----------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| -| [Click To Edit](@/examples/click-to-edit.md) | Demonstrates inline editing of a data object | -| [Bulk Update](@/examples/bulk-update.md) | Demonstrates bulk updating of multiple rows of data | -| [Click To Load](@/examples/click-to-load.md) | Demonstrates clicking to load more rows in a table | -| [Delete Row](@/examples/delete-row.md) | Demonstrates row deletion in a table | -| [Edit Row](@/examples/edit-row.md) | Demonstrates how to edit rows in a table | -| [Lazy Loading](@/examples/lazy-load.md) | Demonstrates how to lazy load content | -| [Inline Validation](@/examples/inline-validation.md) | Demonstrates how to do inline field validation | -| [Infinite Scroll](@/examples/infinite-scroll.md) | Demonstrates infinite scrolling of a page | -| [Active Search](@/examples/active-search.md) | Demonstrates the active search box pattern | -| [Progress Bar](@/examples/progress-bar.md) | Demonstrates a job-runner like progress bar | -| [Value Select](@/examples/value-select.md) | Demonstrates making the values of a select dependent on another select | -| [Animations](@/examples/animations.md) | Demonstrates various animation techniques | -| [File Upload](@/examples/file-upload.md) | Demonstrates how to upload a file via ajax with a progress bar | -| [Preserving File Inputs after Form Errors](@/examples/file-upload-input.md) | Demonstrates how to preserve file inputs after form errors | -| [Reset User Input](@/examples/reset-user-input.md) | Demonstrates how to reset form inputs after submission | -| [Dialogs - Browser](@/examples/dialogs.md) | Demonstrates the prompt and confirm dialogs | -| [Dialogs - UIKit](@/examples/modal-uikit.md) | Demonstrates modal dialogs using UIKit | -| [Dialogs - Bootstrap](@/examples/modal-bootstrap.md) | Demonstrates modal dialogs using Bootstrap | -| [Dialogs - Custom](@/examples/modal-custom.md) | Demonstrates modal dialogs from scratch | -| [Tabs (Using HATEOAS)](@/examples/tabs-hateoas.md) | Demonstrates how to display and select tabs using HATEOAS principles | -| [Tabs (Using JavaScript)](@/examples/tabs-javascript.md) | Demonstrates how to display and select tabs using JavaScript | -| [Keyboard Shortcuts](@/examples/keyboard-shortcuts.md) | Demonstrates how to create keyboard shortcuts for htmx enabled elements | -| [Drag & Drop / Sortable](@/examples/sortable.md) | Demonstrates how to use htmx with the Sortable.js plugin to implement drag-and-drop reordering | -| [Updating Other Content](@/examples/update-other-content.md) | Demonstrates how to update content beyond just the target elements | -| [Confirm](@/examples/confirm.md) | Demonstrates how to implement a custom confirmation dialog with htmx | -| [Async Authentication](@/examples/async-auth.md) | Demonstrates how to handle async authentication tokens in htmx | -| [Web Components](@/examples/web-components.md) | Demonstrates how to integrate htmx with web components and shadow DOM | -| [(Experimental) moveBefore()-powered hx-preserve](/examples/move-before) | htmx will use the experimental [`moveBefore()`](https://cr-status.appspot.com/feature/5135990159835136) API for moving elements, if it is present. | +| [Click To Edit](@/patterns/click-to-edit.md) | Demonstrates inline editing of a data object | +| [Bulk Update](@/patterns/bulk-update.md) | Demonstrates bulk updating of multiple rows of data | +| [Click To Load](@/patterns/click-to-load.md) | Demonstrates clicking to load more rows in a table | +| [Delete Row](@/patterns/delete-row.md) | Demonstrates row deletion in a table | +| [Edit Row](@/patterns/edit-row.md) | Demonstrates how to edit rows in a table | +| [Lazy Loading](@/patterns/lazy-load.md) | Demonstrates how to lazy load content | +| [Inline Validation](@/patterns/inline-validation.md) | Demonstrates how to do inline field validation | +| [Infinite Scroll](@/patterns/infinite-scroll.md) | Demonstrates infinite scrolling of a page | +| [Active Search](@/patterns/active-search.md) | Demonstrates the active search box pattern | +| [Progress Bar](@/patterns/progress-bar.md) | Demonstrates a job-runner like progress bar | +| [Value Select](@/patterns/value-select.md) | Demonstrates making the values of a select dependent on another select | +| [Animations](@/patterns/animations.md) | Demonstrates various animation techniques | +| [File Upload](@/patterns/file-upload.md) | Demonstrates how to upload a file via ajax with a progress bar | +| [Preserving File Inputs after Form Errors](@/patterns/file-upload-input.md) | Demonstrates how to preserve file inputs after form errors | +| [Reset User Input](@/patterns/reset-user-input.md) | Demonstrates how to reset form inputs after submission | +| [Dialogs - Browser](@/patterns/dialogs.md) | Demonstrates the prompt and confirm dialogs | +| [Dialogs - UIKit](@/patterns/modal-uikit.md) | Demonstrates modal dialogs using UIKit | +| [Dialogs - Bootstrap](@/patterns/modal-bootstrap.md) | Demonstrates modal dialogs using Bootstrap | +| [Dialogs - Custom](@/patterns/modal-custom.md) | Demonstrates modal dialogs from scratch | +| [Tabs (Using HATEOAS)](@/patterns/tabs-hateoas.md) | Demonstrates how to display and select tabs using HATEOAS principles | +| [Tabs (Using JavaScript)](@/patterns/tabs-javascript.md) | Demonstrates how to display and select tabs using JavaScript | +| [Keyboard Shortcuts](@/patterns/keyboard-shortcuts.md) | Demonstrates how to create keyboard shortcuts for htmx enabled elements | +| [Drag & Drop / Sortable](@/patterns/sortable.md) | Demonstrates how to use htmx with the Sortable.js plugin to implement drag-and-drop reordering | +| [Updating Other Content](@/patterns/update-other-content.md) | Demonstrates how to update content beyond just the target elements | +| [Confirm](@/patterns/confirm.md) | Demonstrates how to implement a custom confirmation dialog with htmx | +| [Async Authentication](@/patterns/async-auth.md) | Demonstrates how to handle async authentication tokens in htmx | +| [Web Components](@/patterns/web-components.md) | Demonstrates how to integrate htmx with web components and shadow DOM | +| [(Experimental) moveBefore()-powered hx-preserve](/patterns/move-before) | htmx will use the experimental [`moveBefore()`](https://cr-status.appspot.com/feature/5135990159835136) API for moving elements, if it is present. | ## Migrating from Hotwire / Turbo ? diff --git a/www/content/examples/active-search.md b/www/content/patterns/active-search.md similarity index 100% rename from www/content/examples/active-search.md rename to www/content/patterns/active-search.md diff --git a/www/content/examples/animations.md b/www/content/patterns/animations.md similarity index 99% rename from www/content/examples/animations.md rename to www/content/patterns/animations.md index 3fea442c..a94f2cd0 100644 --- a/www/content/examples/animations.md +++ b/www/content/patterns/animations.md @@ -71,7 +71,7 @@ Because the div has a stable id, `color-demo`, htmx will structure the swap such ### Smooth Progress Bar -The [Progress Bar](@/examples/progress-bar.md) demo uses this basic CSS animation technique as well, by updating the `length` +The [Progress Bar](@/patterns/progress-bar.md) demo uses this basic CSS animation technique as well, by updating the `length` property of a progress bar element, allowing for a smooth animation. ## Swap Transitions {#swapping} diff --git a/www/content/examples/async-auth.md b/www/content/patterns/async-auth.md similarity index 100% rename from www/content/examples/async-auth.md rename to www/content/patterns/async-auth.md diff --git a/www/content/examples/bulk-update.md b/www/content/patterns/bulk-update.md similarity index 100% rename from www/content/examples/bulk-update.md rename to www/content/patterns/bulk-update.md diff --git a/www/content/examples/click-to-edit.md b/www/content/patterns/click-to-edit.md similarity index 100% rename from www/content/examples/click-to-edit.md rename to www/content/patterns/click-to-edit.md diff --git a/www/content/examples/click-to-load.md b/www/content/patterns/click-to-load.md similarity index 100% rename from www/content/examples/click-to-load.md rename to www/content/patterns/click-to-load.md diff --git a/www/content/examples/confirm.md b/www/content/patterns/confirm.md similarity index 100% rename from www/content/examples/confirm.md rename to www/content/patterns/confirm.md diff --git a/www/content/examples/delete-row.md b/www/content/patterns/delete-row.md similarity index 100% rename from www/content/examples/delete-row.md rename to www/content/patterns/delete-row.md diff --git a/www/content/examples/dialogs.md b/www/content/patterns/dialogs.md similarity index 100% rename from www/content/examples/dialogs.md rename to www/content/patterns/dialogs.md diff --git a/www/content/examples/edit-row.md b/www/content/patterns/edit-row.md similarity index 100% rename from www/content/examples/edit-row.md rename to www/content/patterns/edit-row.md diff --git a/www/content/examples/file-upload-input.md b/www/content/patterns/file-upload-input.md similarity index 100% rename from www/content/examples/file-upload-input.md rename to www/content/patterns/file-upload-input.md diff --git a/www/content/examples/file-upload.md b/www/content/patterns/file-upload.md similarity index 100% rename from www/content/examples/file-upload.md rename to www/content/patterns/file-upload.md diff --git a/www/content/examples/infinite-scroll.md b/www/content/patterns/infinite-scroll.md similarity index 100% rename from www/content/examples/infinite-scroll.md rename to www/content/patterns/infinite-scroll.md diff --git a/www/content/examples/inline-validation.md b/www/content/patterns/inline-validation.md similarity index 100% rename from www/content/examples/inline-validation.md rename to www/content/patterns/inline-validation.md diff --git a/www/content/examples/keyboard-shortcuts.md b/www/content/patterns/keyboard-shortcuts.md similarity index 100% rename from www/content/examples/keyboard-shortcuts.md rename to www/content/patterns/keyboard-shortcuts.md diff --git a/www/content/examples/lazy-load.md b/www/content/patterns/lazy-load.md similarity index 100% rename from www/content/examples/lazy-load.md rename to www/content/patterns/lazy-load.md diff --git a/www/content/examples/modal-bootstrap.md b/www/content/patterns/modal-bootstrap.md similarity index 100% rename from www/content/examples/modal-bootstrap.md rename to www/content/patterns/modal-bootstrap.md diff --git a/www/content/examples/modal-custom.md b/www/content/patterns/modal-custom.md similarity index 98% rename from www/content/examples/modal-custom.md rename to www/content/patterns/modal-custom.md index c45ffa21..48b3d543 100644 --- a/www/content/examples/modal-custom.md +++ b/www/content/patterns/modal-custom.md @@ -3,7 +3,7 @@ title = "Custom Modal Dialogs" template = "demo.html" +++ -While htmx works great with dialogs built into CSS frameworks (like [Bootstrap](@/examples/modal-bootstrap.md) and [UIKit](@/examples/modal-uikit.md)), htmx also makes +While htmx works great with dialogs built into CSS frameworks (like [Bootstrap](@/patterns/modal-bootstrap.md) and [UIKit](@/patterns/modal-uikit.md)), htmx also makes it easy to build modal dialogs from scratch. Here is a quick example of one way to build them. Click here to see a demo of the final result: diff --git a/www/content/examples/modal-uikit.md b/www/content/patterns/modal-uikit.md similarity index 100% rename from www/content/examples/modal-uikit.md rename to www/content/patterns/modal-uikit.md diff --git a/www/content/examples/move-before/_index.md b/www/content/patterns/move-before/_index.md similarity index 100% rename from www/content/examples/move-before/_index.md rename to www/content/patterns/move-before/_index.md diff --git a/www/content/examples/move-before/details.md b/www/content/patterns/move-before/details.md similarity index 100% rename from www/content/examples/move-before/details.md rename to www/content/patterns/move-before/details.md diff --git a/www/content/examples/progress-bar.md b/www/content/patterns/progress-bar.md similarity index 100% rename from www/content/examples/progress-bar.md rename to www/content/patterns/progress-bar.md diff --git a/www/content/examples/reset-user-input.md b/www/content/patterns/reset-user-input.md similarity index 100% rename from www/content/examples/reset-user-input.md rename to www/content/patterns/reset-user-input.md diff --git a/www/content/examples/sortable.md b/www/content/patterns/sortable.md similarity index 100% rename from www/content/examples/sortable.md rename to www/content/patterns/sortable.md diff --git a/www/content/examples/tabs-hateoas.md b/www/content/patterns/tabs-hateoas.md similarity index 99% rename from www/content/examples/tabs-hateoas.md rename to www/content/patterns/tabs-hateoas.md index cdfb178d..9de86ad8 100644 --- a/www/content/examples/tabs-hateoas.md +++ b/www/content/patterns/tabs-hateoas.md @@ -3,7 +3,7 @@ title = "Tabs (Using HATEOAS)" template = "demo.html" +++ -This example shows how easy it is to implement tabs using htmx. Following the principle of [Hypertext As The Engine Of Application State](https://en.wikipedia.org/wiki/HATEOAS), the selected tab is a part of the application state. Therefore, to display and select tabs in your application, simply include the tab markup in the returned HTML. If this does not suit your application server design, you can also use a little bit of [JavaScript to select tabs instead](@/examples/tabs-javascript.md). +This example shows how easy it is to implement tabs using htmx. Following the principle of [Hypertext As The Engine Of Application State](https://en.wikipedia.org/wiki/HATEOAS), the selected tab is a part of the application state. Therefore, to display and select tabs in your application, simply include the tab markup in the returned HTML. If this does not suit your application server design, you can also use a little bit of [JavaScript to select tabs instead](@/patterns/tabs-javascript.md). ## Example Code (Main Page) The main page simply includes the following HTML to load the initial tab into the DOM. diff --git a/www/content/examples/tabs-javascript.md b/www/content/patterns/tabs-javascript.md similarity index 98% rename from www/content/examples/tabs-javascript.md rename to www/content/patterns/tabs-javascript.md index ffeb9004..d3126060 100644 --- a/www/content/examples/tabs-javascript.md +++ b/www/content/patterns/tabs-javascript.md @@ -7,7 +7,7 @@ This example shows how to load tab contents using htmx, and to select the "activ some duplication by offloading some of the work of re-rendering the tab HTML from your application server to your clients' browsers. -You may also consider [a more idiomatic approach](@/examples/tabs-hateoas.md) that follows the principle of [Hypertext As The Engine Of Application State](https://en.wikipedia.org/wiki/HATEOAS). +You may also consider [a more idiomatic approach](@/patterns/tabs-hateoas.md) that follows the principle of [Hypertext As The Engine Of Application State](https://en.wikipedia.org/wiki/HATEOAS). ## Example Code diff --git a/www/content/examples/update-other-content.md b/www/content/patterns/update-other-content.md similarity index 100% rename from www/content/examples/update-other-content.md rename to www/content/patterns/update-other-content.md diff --git a/www/content/examples/value-select.md b/www/content/patterns/value-select.md similarity index 100% rename from www/content/examples/value-select.md rename to www/content/patterns/value-select.md diff --git a/www/content/examples/web-components.md b/www/content/patterns/web-components.md similarity index 100% rename from www/content/examples/web-components.md rename to www/content/patterns/web-components.md diff --git a/www/content/posts/2020-11-24-htmx-1.0.0-is-released.md b/www/content/posts/2020-11-24-htmx-1.0.0-is-released.md index e0098352..68dacb16 100644 --- a/www/content/posts/2020-11-24-htmx-1.0.0-is-released.md +++ b/www/content/posts/2020-11-24-htmx-1.0.0-is-released.md @@ -23,7 +23,7 @@ As the [homepage says](@/_index.md): * Why should only GET & POST be available? * Why should you only be able to replace the entire screen? -HTML-oriented web development was abandoned not because hypertext was a bad idea, but rather because HTML didn't have sufficient expressive power. htmx aims to fix that & allows you to implement [many common modern web UI patterns](@/examples/_index.md) using the original hypertext model of the web. +HTML-oriented web development was abandoned not because hypertext was a bad idea, but rather because HTML didn't have sufficient expressive power. htmx aims to fix that & allows you to implement [many common modern web UI patterns](@/patterns/_index.md) using the original hypertext model of the web. ### History & Thanks diff --git a/www/content/posts/2020-5-15-kutty-0.0.1-is-released.md b/www/content/posts/2020-5-15-kutty-0.0.1-is-released.md index e8270bd3..04b67888 100644 --- a/www/content/posts/2020-5-15-kutty-0.0.1-is-released.md +++ b/www/content/posts/2020-5-15-kutty-0.0.1-is-released.md @@ -32,7 +32,7 @@ I chose to rename the project for a few reasons: * Kutty isn't the kitchen-sink-of-features that intercooler is. Kutty is more focused on the features that are amenable to a declarative approache and less on replacing javascript entirely. * Kutty has a better swapping mechanism which introduces a settling step, which allows for nice CSS transitions - with minimal complexity. Check out the [progress bar](@/examples/progress-bar.md) to see how this works: by returning + with minimal complexity. Check out the [progress bar](@/patterns/progress-bar.md) to see how this works: by returning HTML in the old web 1.0 style, you can get nice, smooth CSS-based transitions. Fun! Beyond that, basic kutty and intercooler code will look a lot a like: diff --git a/www/content/posts/2021-1-6-htmx-1.1.0-is-released.md b/www/content/posts/2021-1-6-htmx-1.1.0-is-released.md index a4c2f77f..0afa6220 100644 --- a/www/content/posts/2021-1-6-htmx-1.1.0-is-released.md +++ b/www/content/posts/2021-1-6-htmx-1.1.0-is-released.md @@ -13,8 +13,8 @@ This is a surprisingly big release, but the star of the show isn't htmx itself, [preload extension](https://github.com/bigskysoftware/htmx-extensions/blob/main/src/preload/README.md) which allows you to preload requests into the cache, cutting down on latency. (This extension is used in the htmx website!) -There are also new examples, including [keyboard shortcuts](@/examples/keyboard-shortcuts.md) and -[drag and drop list reordering with Sortable.js](@/examples/sortable.md). +There are also new examples, including [keyboard shortcuts](@/patterns/keyboard-shortcuts.md) and +[drag and drop list reordering with Sortable.js](@/patterns/sortable.md). ### Changes diff --git a/www/content/posts/2024-06-17-htmx-2.0.0-is-released.md b/www/content/posts/2024-06-17-htmx-2.0.0-is-released.md index 35572e17..9a5d9b4f 100644 --- a/www/content/posts/2024-06-17-htmx-2.0.0-is-released.md +++ b/www/content/posts/2024-06-17-htmx-2.0.0-is-released.md @@ -45,7 +45,7 @@ remain `latest` and the 2.0 line will remain `next` until Jan 1, 2025. The websi Not much, really: * The `selectAndSwap()` internal API method was replaced with the public (and much better) [`swap()`](/api/#swap) method -* Web Component support has been [improved dramatically](@/examples/web-components.md) +* Web Component support has been [improved dramatically](@/patterns/web-components.md) * And the biggest feature of this release: [the website](https://htmx.org) now supports dark mode! (Thanks [@pokonski](https://github.com/pokonski)!) A complete upgrade guide can be found here: diff --git a/www/templates/demo.html b/www/templates/demo.html index 64f9b34a..7a82d40f 100644 --- a/www/templates/demo.html +++ b/www/templates/demo.html @@ -1,7 +1,7 @@ {% extends "htmx-theme/templates/base.html" %} {% block title %} -{% set html_title = "</> htmx ~ Examples ~ " ~ page.title %} +{% set html_title = "</> htmx ~ Patterns ~ " ~ page.title %} {% endblock title %} {% block description %} diff --git a/www/themes/htmx-theme/templates/base.html b/www/themes/htmx-theme/templates/base.html index acdd638b..15f729ed 100644 --- a/www/themes/htmx-theme/templates/base.html +++ b/www/themes/htmx-theme/templates/base.html @@ -32,7 +32,7 @@