mirror of
https://github.com/bigskysoftware/htmx.git
synced 2026-01-20 23:56:06 +00:00
Merge branch 'refs/heads/examples-to-patterns' into four
This commit is contained in:
commit
1aeb2a663a
@ -121,7 +121,7 @@ if(window.location.search=="?ads=true") {
|
||||
|
||||
htmx gives you access to [`fetch()`](@/docs.md#ajax), [View Transitions](@/docs.md#), [Streaming Responses](@/docs.md) and more
|
||||
directly in HTML, using [attributes](@/reference.md#attributes), so you can build
|
||||
[interactive interfaces](@/examples/_index.md) with the [simplicity](https://en.wikipedia.org/wiki/HATEOAS) and
|
||||
[interactive 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 ([~10k min.br'd](https://cdn.jsdelivr.net/npm/htmx.org/dist/)),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -265,7 +265,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:<CSS Selector>` - 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
|
||||
<input type="text" name="q"
|
||||
@ -341,7 +341,7 @@ If the `/messages` end point keeps returning a div set up this way, it will keep
|
||||
second.
|
||||
|
||||
Load polling can be useful in situations where a poll has an end point at which point the polling terminates, such as
|
||||
when you are showing the user a [progress bar](@/examples/progress-bar.md).
|
||||
when you are showing the user a [progress bar](@/patterns/progress-bar.md).
|
||||
|
||||
### Request Indicators {#indicators}
|
||||
|
||||
@ -500,7 +500,7 @@ View Transitions can be configured using CSS, as outlined in [the Chrome documen
|
||||
<p>more docs on this</p>
|
||||
</aside>
|
||||
|
||||
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
|
||||
|
||||
@ -696,7 +696,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}
|
||||
|
||||
@ -842,7 +842,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.
|
||||
|
||||
@ -1219,7 +1219,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
|
||||
|
||||
@ -1539,7 +1539,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
|
||||
@ -1555,7 +1555,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
|
||||
<form class="sortable" hx-post="/items" hx-trigger="end">
|
||||
@ -1590,7 +1590,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
|
||||
@ -1825,4 +1825,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!
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
<h3>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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_
|
||||
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
+++
|
||||
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 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`
|
||||
|
||||
```html
|
||||
<div hx-target="this" hx-swap="outerHTML">
|
||||
<div><label>First Name</label>: Joe</div>
|
||||
<div><label>Last Name</label>: Blow</div>
|
||||
<div><label>Email</label>: joe@blow.com</div>
|
||||
<button hx-get="/contact/1/edit" class="btn primary">
|
||||
Click To Edit
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
* This returns a form that can be used to edit the contact
|
||||
|
||||
```html
|
||||
<form hx-put="/contact/1" hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label>First Name</label>
|
||||
<input type="text" name="firstName" value="Joe">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Last Name</label>
|
||||
<input type="text" name="lastName" value="Blow">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Email Address</label>
|
||||
<input type="email" name="email" value="joe@blow.com">
|
||||
</div>
|
||||
<button class="btn" type="submit">Submit</button>
|
||||
<button class="btn" hx-get="/contact/1">Cancel</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
* The form issues a `PUT` back to `/contact/1`, following the usual REST-ful pattern.
|
||||
|
||||
{{ demoenv() }}
|
||||
|
||||
<script>
|
||||
//=========================================================================
|
||||
// Fake Server Side Code
|
||||
//=========================================================================
|
||||
|
||||
// data
|
||||
var contact = {
|
||||
"firstName" : "Joe",
|
||||
"lastName" : "Blow",
|
||||
"email" : "joe@blow.com"
|
||||
};
|
||||
|
||||
// routes
|
||||
init("/contact/1", function(request){
|
||||
return displayTemplate(contact);
|
||||
});
|
||||
|
||||
onGet("/contact/1/edit", function(request){
|
||||
return formTemplate(contact);
|
||||
});
|
||||
|
||||
onPut("/contact/1", function (req, params) {
|
||||
contact.firstName = params['firstName'];
|
||||
contact.lastName = params['lastName'];
|
||||
contact.email = params['email'];
|
||||
return displayTemplate(contact);
|
||||
});
|
||||
|
||||
// templates
|
||||
function formTemplate(contact) {
|
||||
return `<form hx-put="/contact/1" hx-target="this" hx-swap="outerHTML">
|
||||
<div>
|
||||
<label for="firstName">First Name</label>
|
||||
<input autofocus type="text" id="firstName" name="firstName" value="${contact.firstName}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastName">Last Name</label>
|
||||
<input type="text" id="lastName" name="lastName" value="${contact.lastName}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" value="${contact.email}">
|
||||
</div>
|
||||
<button class="btn primary" type="submit">Submit</button>
|
||||
<button class="btn danger" hx-get="/contact/1">Cancel</button>
|
||||
</form>`
|
||||
}
|
||||
|
||||
function displayTemplate(contact) {
|
||||
return `<div hx-target="this" hx-swap="outerHTML">
|
||||
<div><label>First Name</label>: ${contact.firstName}</div>
|
||||
<div><label>Last Name</label>: ${contact.lastName}</div>
|
||||
<div><label>Email Address</label>: ${contact.email}</div>
|
||||
<button hx-get="/contact/1/edit" class="btn primary">
|
||||
Click To Edit
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
</script>
|
||||
@ -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 ?
|
||||
|
||||
@ -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() }}
|
||||
|
||||
<script>
|
||||
|
||||
@ -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}
|
||||
@ -72,7 +72,7 @@ You can see a working example of this code below.
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
//=========================================================================
|
||||
114
www/content/patterns/click-to-edit.md
Normal file
114
www/content/patterns/click-to-edit.md
Normal file
@ -0,0 +1,114 @@
|
||||
+++
|
||||
title = "Click to Edit"
|
||||
template = "demo.html"
|
||||
+++
|
||||
|
||||
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: **View Mode** & **Edit Mode**.
|
||||
|
||||
### 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
|
||||
<div hx-target:inherited="this">
|
||||
|
||||
<p>Name: <span>{{ user.name }}</span></p>
|
||||
|
||||
<!-- On click, switch to edit mode -->
|
||||
<button hx-get="/users/1/edit"
|
||||
hx-swap="outerHTML">
|
||||
Edit
|
||||
</button>
|
||||
|
||||
</div>
|
||||
```
|
||||
_The \<button\> `GET`s the edit form & replaces the parent `<div>` with it._
|
||||
|
||||
|
||||
### 2. Edit Mode
|
||||
|
||||
In edit mode, show a form with **Save** & **Cancel** options.
|
||||
|
||||
```html
|
||||
<!-- On submit, save changes & return to view mode -->
|
||||
<form hx-put="/users/1"
|
||||
hx-target:inherited="this"
|
||||
hx-swap:inherited="outerHTML">
|
||||
|
||||
<p>Name: <input name="name" value="{{ user.name }}"></p>
|
||||
|
||||
<button type="submit">
|
||||
Save
|
||||
</button>
|
||||
|
||||
<!-- On click, return to view mode (without saving) -->
|
||||
<button type="button" hx-get="/users/1">
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
</form>
|
||||
```
|
||||
_The form `PUT`s the updated value to the server, which returns the updated view mode HTML to replace the form._
|
||||
|
||||
**Note:**
|
||||
|
||||
The endpoints follow REST conventions:
|
||||
- `GET /users/1` - Retrieve the current view
|
||||
- `GET /users/1/edit` - Retrieve the edit form
|
||||
- `PUT /users/1` - Update the resource
|
||||
|
||||
The URL represents the resource (`/users/1`), and the HTTP method indicates the action.
|
||||
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
const user = { name: "Joe Smith" };
|
||||
|
||||
init("/users/1", () => `
|
||||
<div hx-target:inherited="this">
|
||||
<p>Name: <span>${user.name}</span></p>
|
||||
<button hx-get="/users/1/edit"
|
||||
hx-swap="outerHTML">
|
||||
Edit
|
||||
</button>
|
||||
</div>`);
|
||||
|
||||
onGet("/users/1/edit", () => `
|
||||
<form hx-put="/users/1"
|
||||
hx-target:inherited="this"
|
||||
hx-swap:inherited="outerHTML">
|
||||
<p>Name: <input name="name" value="${user.name}"></p>
|
||||
<button type="submit">
|
||||
Save
|
||||
</button>
|
||||
<button hx-get="/users/1">
|
||||
Cancel
|
||||
</button>
|
||||
</form>`);
|
||||
|
||||
onPut("/users/1", (req, params) => {
|
||||
user.name = params.name;
|
||||
|
||||
return `
|
||||
<div hx-target:inherited="this"
|
||||
hx-swap:inherited="outerHTML">
|
||||
<p>Name: <span>${user.name}</span></p>
|
||||
<button hx-get="/users/1/edit">
|
||||
Edit
|
||||
</button>
|
||||
</div>`});
|
||||
</script>
|
||||
|
||||
<style type="text/tailwindcss">
|
||||
#demo-content > div, #demo-content > form {
|
||||
@apply p-4 border border-gray-300 rounded shadow max-w-md;
|
||||
}
|
||||
#demo-content p {
|
||||
@apply h-[34px]
|
||||
}
|
||||
#demo-content input {
|
||||
@apply px-2 py-0.5 border border-gray-400 rounded shadow-inner;
|
||||
}
|
||||
</style>
|
||||
@ -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() }}
|
||||
|
||||
<script>
|
||||
//=========================================================================
|
||||
@ -61,7 +61,7 @@ tr.htmx-swapping td {
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
//=========================================================================
|
||||
@ -91,7 +91,7 @@ there is an [`hx-include`](@/attributes/hx-include.md) that includes all the inp
|
||||
notoriously difficult to use with forms due to HTML constraints (you can't put a `form` directly inside a `tr`) so
|
||||
this makes things a bit nicer to deal with.
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
@ -22,7 +22,7 @@ The last element of the results will itself contain the listener to load the *ne
|
||||
|
||||
> `revealed` - triggered when an element is scrolled into the viewport (also useful for lazy-loading). If you are using `overflow` in css like `overflow-y: scroll` you should use `intersect once` instead of `revealed`.
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
server.autoRespondAfter = 1000; // longer response for more drama
|
||||
@ -76,7 +76,7 @@ Below is a working demo of this example. The only email that will be accepted i
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
|
||||
@ -22,7 +22,7 @@ You can find out the conditions needed for a given keyboard shortcut here:
|
||||
|
||||
[https://javascript.info/keyboard-events](https://javascript.info/keyboard-events)
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
|
||||
@ -33,7 +33,7 @@ img {
|
||||
}
|
||||
</style>
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
server.autoRespondAfter = 2000; // longer response for more drama
|
||||
@ -60,7 +60,7 @@ tabindex="-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<style>
|
||||
@import "https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.2.2/css/bootstrap.min.css";
|
||||
@ -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:
|
||||
@ -83,7 +83,7 @@ window.document.getElementById("cancelButton").addEventListener("click", functio
|
||||
|
||||
<div id="modals-here"></div>
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<style>
|
||||
@import "https://cdnjs.cloudflare.com/ajax/libs/uikit/3.5.9/css/uikit-core.min.css";
|
||||
@ -92,7 +92,7 @@ This example uses styling cribbed from the bootstrap progress bar:
|
||||
}
|
||||
```
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<style>
|
||||
.progress {
|
||||
@ -44,7 +44,7 @@ The following code is functionally equivalent:
|
||||
<ul id="notes"><!-- Response will go here --></ul>
|
||||
```
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
|
||||
@ -58,7 +58,7 @@ the item ids in the new order to `/items`, to be persisted by the server.
|
||||
|
||||
That's it!
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script>
|
||||
@ -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.
|
||||
@ -31,7 +31,7 @@ Subsequent tab pages display all tabs and highlight the selected one accordingly
|
||||
</div>
|
||||
```
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<div id="tabs" hx-target="this" hx-swap="innerHTML">
|
||||
<div class="tab-list" role="tablist">
|
||||
@ -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
|
||||
|
||||
@ -32,7 +32,7 @@ when the content is swapped into the DOM.
|
||||
<div id="tab-contents" role="tabpanel" hx-get="/tab1" hx-trigger="load"></div>
|
||||
```
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<div id="tabs" hx-target="#tab-contents" role="tablist"
|
||||
hx-on:htmx-after-on-load="console.log(event)
|
||||
@ -39,7 +39,7 @@ When a request is made to the `/models` end point, we return the models for that
|
||||
|
||||
And they become available in the `model` select.
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
|
||||
@ -34,7 +34,7 @@ The same principles generally apply to web components that don't use shadow DOM
|
||||
won't be encapsulated like with shadow DOM, you'll still have to point HTMX to your component's content by
|
||||
calling `htmx.process`.
|
||||
|
||||
{{ demoenv() }}
|
||||
{{ demo_environment() }}
|
||||
|
||||
<script>
|
||||
//=========================================================================
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,195 +0,0 @@
|
||||
//====================================
|
||||
// Server setup
|
||||
//====================================
|
||||
var server = sinon.fakeServer.create();
|
||||
server.fakeHTTPMethods = true;
|
||||
server.getHTTPMethod = function (xhr) {
|
||||
return xhr.requestHeaders['X-HTTP-Method-Override'] || xhr.method;
|
||||
}
|
||||
server.autoRespond = true;
|
||||
server.autoRespondAfter = 80;
|
||||
server.xhr.useFilters = true;
|
||||
server.xhr.addFilter(function (method, url, async, username, password) {
|
||||
return url === "/" || url.indexOf("http") === 0;
|
||||
})
|
||||
|
||||
//====================================
|
||||
// Request Handling
|
||||
//====================================
|
||||
|
||||
function parseParams(str) {
|
||||
var re = /([^&=]+)=?([^&]*)/g;
|
||||
var decode = function (str) {
|
||||
return decodeURIComponent(str.replace(/\+/g, ' '));
|
||||
};
|
||||
var params = {}, e;
|
||||
if (str) {
|
||||
if (str.substr(0, 1) == '?') {
|
||||
str = str.substr(1);
|
||||
}
|
||||
while (e = re.exec(str)) {
|
||||
var k = encodeHTML(decode(e[1]));
|
||||
var v = encodeHTML(decode(e[2]));
|
||||
if (params[k] !== undefined) {
|
||||
if (!Array.isArray(params[k])) {
|
||||
params[k] = [params[k]];
|
||||
}
|
||||
params[k].push(v);
|
||||
} else {
|
||||
params[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function getQuery(url) {
|
||||
var question = url.indexOf("?");
|
||||
var hash = url.indexOf("#");
|
||||
if (hash == -1 && question == -1) return "";
|
||||
if (hash == -1) hash = url.length;
|
||||
return question == -1 || hash == question + 1 ? url.substring(hash) :
|
||||
url.substring(question + 1, hash);
|
||||
}
|
||||
|
||||
function encodeHTML(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function params(request) {
|
||||
if (server.getHTTPMethod(request) == "GET") {
|
||||
return parseParams(getQuery(request.url));
|
||||
} else {
|
||||
return parseParams(request.requestBody);
|
||||
}
|
||||
}
|
||||
function headers(request) {
|
||||
return request.getAllResponseHeaders().split("\r\n").filter(h => h.toLowerCase().startsWith("hx-")).map(h => h.split(": ")).reduce((acc, v) => ({ ...acc, [v[0]]: v[1] }), {})
|
||||
}
|
||||
|
||||
//====================================
|
||||
// Routing
|
||||
//====================================
|
||||
|
||||
function init(path, response) {
|
||||
onGet(path, response);
|
||||
let content = response(null, {});
|
||||
let canvas = document.getElementById("demo-canvas");
|
||||
if (canvas) {
|
||||
canvas.innerHTML = content;
|
||||
pushActivityChip("Initial State", "init", demoInitialStateTemplate(content));
|
||||
}
|
||||
}
|
||||
|
||||
function onGet(path, response) {
|
||||
server.respondWith("GET", path, function (request) {
|
||||
let headers = {};
|
||||
let body = response(request, params(request), headers);
|
||||
request.respond(200, headers, body);
|
||||
});
|
||||
}
|
||||
|
||||
function onPut(path, response) {
|
||||
server.respondWith("PUT", path, function (request) {
|
||||
let headers = {};
|
||||
let body = response(request, params(request), headers);
|
||||
request.respond(200, headers, body);
|
||||
});
|
||||
}
|
||||
|
||||
function onPost(path, response) {
|
||||
server.respondWith("POST", path, function (request) {
|
||||
let headers = {};
|
||||
let body = response(request, params(request), headers);
|
||||
request.respond(200, headers, body);
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete(path, response) {
|
||||
server.respondWith("DELETE", path, function (request) {
|
||||
let headers = {};
|
||||
let body = response(request, params(request), headers);
|
||||
request.respond(200, headers, body);
|
||||
});
|
||||
}
|
||||
|
||||
//====================================
|
||||
// Activities
|
||||
//====================================
|
||||
|
||||
var requestId = 0;
|
||||
htmx.on("htmx:beforeSwap", function (event) {
|
||||
if (document.getElementById("request-count")) {
|
||||
requestId++;
|
||||
pushActivityChip(`${server.getHTTPMethod(event.detail.xhr)} ${event.detail.xhr.url}`, `req-${requestId}`, demoResponseTemplate(event.detail));
|
||||
document.getElementById("request-count").innerText = ": " + requestId;
|
||||
}
|
||||
});
|
||||
|
||||
function showTimelineEntry(id) {
|
||||
var children = document.getElementById("demo-current-request").children;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var child = children[i];
|
||||
if (child.id == id) {
|
||||
child.classList.remove('hide');
|
||||
} else {
|
||||
child.classList.add('hide');
|
||||
}
|
||||
}
|
||||
var children = document.getElementById("demo-timeline").children;
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
var child = children[i];
|
||||
if (child.id == id + "-link") {
|
||||
child.classList.add('active');
|
||||
} else {
|
||||
child.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pushActivityChip(name, id, content) {
|
||||
document.getElementById("demo-timeline").insertAdjacentHTML("afterbegin", `<li id="${id}-link"><a onclick="showTimelineEntry('${id}')" style="cursor: pointer">${name}</a></li>`);
|
||||
if (content.length > 750) {
|
||||
content = content.substr(0, 750) + "...";
|
||||
}
|
||||
var contentDiv = `<div id="${id}">${content}</div>`;
|
||||
document.getElementById("demo-current-request").insertAdjacentHTML("afterbegin", contentDiv);
|
||||
showTimelineEntry(id);
|
||||
//Prism.highlightAll();
|
||||
}
|
||||
|
||||
//====================================
|
||||
// Templates
|
||||
//====================================
|
||||
|
||||
function escapeHtml(string) {
|
||||
var pre = document.createElement('pre');
|
||||
var text = document.createTextNode(string);
|
||||
pre.appendChild(text);
|
||||
return pre.innerHTML;
|
||||
}
|
||||
|
||||
function demoInitialStateTemplate(html) {
|
||||
return `<span class="activity initial">
|
||||
<b>HTML</b>
|
||||
<pre class="language-html"><code class="language-html">${escapeHtml(html)}</code></pre>
|
||||
</span>`
|
||||
}
|
||||
|
||||
function demoResponseTemplate(details) {
|
||||
return `<span class="activity response">
|
||||
<div>
|
||||
<b>${server.getHTTPMethod(details.xhr)}</b> ${details.xhr.url}
|
||||
</div>
|
||||
<div>
|
||||
parameters: ${JSON.stringify(params(details.xhr))}
|
||||
</div>
|
||||
<div>
|
||||
headers: ${JSON.stringify(headers(details.xhr))}
|
||||
</div>
|
||||
<div>
|
||||
<b>Response</b>
|
||||
<pre class="language-html"><code class="language-html">${escapeHtml(details.xhr.response)}</code> </pre>
|
||||
</div>
|
||||
</span>`;
|
||||
}
|
||||
55
www/static/js/fetch-mock.js
Normal file
55
www/static/js/fetch-mock.js
Normal file
@ -0,0 +1,55 @@
|
||||
//====================================
|
||||
// Fetch Mock Server
|
||||
//====================================
|
||||
|
||||
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);
|
||||
|
||||
const response = new Response(body, { status: 200, headers });
|
||||
response.mockBody = body; // Store for later access
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
function parseParams(method, url, body) {
|
||||
const parse = str => {
|
||||
const params = {};
|
||||
str?.replace(/([^&=]+)=([^&]*)/g, (_, k, v) => params[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' ')));
|
||||
return Object.keys(params).length > 0 ? params : null;
|
||||
};
|
||||
|
||||
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 Object.keys(params).length > 0 ? params : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
1793
www/static/js/htmx.js
Normal file
1793
www/static/js/htmx.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,25 +1,25 @@
|
||||
{% 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 %}
|
||||
{%- if page.description -%}
|
||||
{{- page.description | safe -}}
|
||||
{%- else -%}
|
||||
{{- super() -}}
|
||||
{%- endif -%}
|
||||
{%- if page.description -%}
|
||||
{{- page.description | safe -}}
|
||||
{%- else -%}
|
||||
{{- super() -}}
|
||||
{%- endif -%}
|
||||
{% endblock description %}
|
||||
|
||||
{% block 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 %}<h1>{{ page.title | safe }}</h1>{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sinon@9.0.2/pkg/sinon.js"></script>
|
||||
<script src="/js/demo.js"></script>
|
||||
{{ 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 %}<h1>{{ page.title | safe }}</h1>{% endif %}
|
||||
{{ page.content | safe }}
|
||||
|
||||
{% endblock content %}
|
||||
153
www/templates/shortcodes/demo_environment.html
Normal file
153
www/templates/shortcodes/demo_environment.html
Normal file
@ -0,0 +1,153 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.14/dist/template.js"></script>
|
||||
<script src="/js/fetch-mock.js"></script>
|
||||
<script>
|
||||
function init(path, response) {
|
||||
onGet(path, response);
|
||||
_hyperscript.evaluate(`put \`<div hx-get="${path}" hx-trigger="load" hx-swap="outerHTML transition:false"></div>\` into #demo-content`)
|
||||
}
|
||||
|
||||
const routes = [];
|
||||
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 }); }
|
||||
</script>
|
||||
|
||||
|
||||
{# ======================================================================== #}
|
||||
{# DEMO SECTION #}
|
||||
{# ======================================================================== #}
|
||||
|
||||
<h2 id="demo">
|
||||
<a class="zola-anchor" href="#demo" aria-label="Anchor link for: demo">Demo</a>
|
||||
</h2>
|
||||
|
||||
<div id="demo-content">
|
||||
<!-- Dynamic content gets loaded here -->
|
||||
</div>
|
||||
|
||||
|
||||
{# ======================================================================== #}
|
||||
{# DEBUG TOOLBAR: Timeline + Request Details #}
|
||||
{# ======================================================================== #}
|
||||
|
||||
<div id="debug-toolbar" class="fixed bottom-0 inset-x-0 z-50 translate-y-[calc(100%-2.5rem)] has-[[name*=toolbar-toggle]:checked]:translate-y-0 transition font-['Lucida_Grande',Geneva,Verdana,sans-serif]">
|
||||
|
||||
{# Header (click to expand/collapse) #}
|
||||
<label class="block px-4 py-2 cursor-pointer select-none bg-[linear-gradient(to_bottom,#fefefe_0%,#ddd_50%,#ccc_51%,#bbb_100%)] border border-[rgba(0,0,0,0.4)] rounded-t shadow-[inset_1px_1px_0_rgba(255,255,255,0.9),inset_-1px_-1px_0_rgba(0,0,0,0.15),0_-2px_8px_rgba(0,0,0,0.15)]">
|
||||
<input name="debug-toolbar-toggle" type="checkbox" class="hidden">
|
||||
<span class="flex items-center gap-4">
|
||||
<span class="font-semibold text-sm [text-shadow:0_1px_0_rgba(255,255,255,0.8)]">
|
||||
Debug Toolbar
|
||||
</span>
|
||||
<span id="toolbar-status" class="text-xs text-[#666] truncate max-w-[60vw] translate-y-px">
|
||||
test
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{# Content #}
|
||||
<div class="flex gap-3 py-3 px-4 max-h-[33vh] sm:px-12 lg:px-24 bg-[linear-gradient(to_bottom,#fefefe_0%,#ddd_50%,#ccc_51%,#bbb_100%)]">
|
||||
|
||||
{# Timeline: List of htmx requests #}
|
||||
<ol id="timeline" class="list-decimal flex flex-col-reverse gap-2 w-fit max-w-1/3 p-4 !m-0 text-sm bg-white border border-[rgba(0,0,0,0.4)] rounded shadow-[inset_1px_1px_0_rgba(0,0,0,0.1)] overflow-y-auto"
|
||||
_="on htmx:after:swap from document
|
||||
set req to event.detail.ctx.request
|
||||
set params to req.parameters or window.parseParams(req.method, req.action, req.body)
|
||||
set request to { method: req.method, action: req.action, parameters: params, headers: req.headers }
|
||||
set response to { status: event.detail.ctx.response.status, body: event.detail.ctx.response.raw.mockBody }
|
||||
render the first <template/> in me with (request: request, response: response)
|
||||
put it at the end of me
|
||||
call me.scrollTo({top: -me.scrollHeight})
|
||||
put `${request.method} ${request.action}` into #toolbar-status">
|
||||
|
||||
<template>
|
||||
<li>
|
||||
<label class="has-checked:bg-[linear-gradient(to_bottom,#5a9eea_0%,#3d7fc9_100%)] has-checked:text-white has-checked:shadow-[inset_0_1px_1px_rgba(0,0,0,0.3)] cursor-pointer flex items-center gap-2 p-2 rounded hover:bg-[rgba(0,0,0,0.05)] transition-colors border border-transparent has-checked:border-[#2d5f9f]"
|
||||
data-request="${request as JSON}"
|
||||
data-response="${response as JSON}"
|
||||
_="on change
|
||||
render first <template/> in #current-request with (request: my.dataset.request as Object, response: my.dataset.response as Object)
|
||||
then put it into first <output/> in #current-request"
|
||||
>
|
||||
<input type="radio" name="current-request" class="hidden">
|
||||
<span class="font-semibold flex-1 truncate has-checked:[text-shadow:0_-1px_0_rgba(0,0,0,0.3)]">${request.method} ${request.action}</span>
|
||||
<span data-status="${response.status}"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold rounded [text-shadow:0_1px_0_rgba(255,255,255,0.5)]
|
||||
data-[status^='2']:bg-[#c8e6c9] data-[status^='2']:text-[#2e7d32] data-[status^='2']:border-[#81c784]
|
||||
data-[status^='3']:bg-[#fff9c4] data-[status^='3']:text-[#f57f17] data-[status^='3']:border-[#fff59d]
|
||||
data-[status^='4']:bg-[#ffcdd2] data-[status^='4']:text-[#c62828] data-[status^='4']:border-[#ef9a9a]
|
||||
data-[status^='5']:bg-[#ffcdd2] data-[status^='5']:text-[#c62828] data-[status^='5']:border-[#ef9a9a]
|
||||
border shadow-[inset_0_1px_0_rgba(255,255,255,0.6)]">
|
||||
${response.status}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
</ol>
|
||||
|
||||
{# Current Request: Parameters, headers, response #}
|
||||
<div id="current-request" class="flex-1 p-6 bg-white border border-[rgba(0,0,0,0.4)] rounded overflow-auto shadow-[inset_1px_1px_0_rgba(0,0,0,0.1)]">
|
||||
|
||||
<template>
|
||||
{# Response Section #}
|
||||
@if response
|
||||
<section class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="!m-0 text-xs font-semibold text-[#555] uppercase tracking-wider [text-shadow:0_1px_0_rgba(255,255,255,0.9)]">
|
||||
Response
|
||||
</h4>
|
||||
<span data-status="${response.status}"
|
||||
class="px-2 py-1 text-[11px] font-bold rounded [text-shadow:0_1px_0_rgba(255,255,255,0.5)]
|
||||
data-[status^='2']:bg-[#c8e6c9] data-[status^='2']:text-[#2e7d32] data-[status^='2']:border-[#81c784]
|
||||
data-[status^='3']:bg-[#fff9c4] data-[status^='3']:text-[#f57f17] data-[status^='3']:border-[#fff59d]
|
||||
data-[status^='4']:bg-[#ffcdd2] data-[status^='4']:text-[#c62828] data-[status^='4']:border-[#ef9a9a]
|
||||
data-[status^='5']:bg-[#ffcdd2] data-[status^='5']:text-[#c62828] data-[status^='5']:border-[#ef9a9a]
|
||||
border shadow-[inset_0_1px_0_rgba(255,255,255,0.6)]">
|
||||
${response.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] font-semibold text-[#888] mb-1 uppercase tracking-wider">
|
||||
Body
|
||||
</div>
|
||||
<div class="bg-[#f8f8f8] border border-[rgba(0,0,0,0.2)] rounded p-2.5 overflow-x-auto">
|
||||
<pre class="!p-0 !m-0 !bg-transparent text-xs font-mono">${response.body}</pre>
|
||||
</div>
|
||||
</section>
|
||||
@end
|
||||
|
||||
{# Request Section #}
|
||||
@if request.parameters or request.headers
|
||||
<section class="pt-8 border-t border-[rgba(0,0,0,0.1)]">
|
||||
<h4 class="!m-0 !mb-4 text-xs font-semibold text-[#555] uppercase tracking-wider [text-shadow:0_1px_0_rgba(255,255,255,0.9)]">
|
||||
Request
|
||||
</h4>
|
||||
|
||||
@repeat for section in [{title: 'Query Parameters', data: request.parameters}, {title: 'Headers', data: request.headers}]
|
||||
@if section.data
|
||||
<div class="mb-4 last:mb-0">
|
||||
<div class="text-[10px] font-semibold text-[#888] mb-1 uppercase tracking-wider">
|
||||
${section.title}
|
||||
</div>
|
||||
<div class="space-y-1 bg-[#f8f8f8] border border-[rgba(0,0,0,0.2)] rounded p-2.5 text-xs font-mono overflow-auto">
|
||||
@repeat for entry in Object.entries(section.data)
|
||||
<div class="flex gap-2 whitespace-nowrap">
|
||||
<span class="text-[#666] font-semibold">${entry[0]}:</span>
|
||||
<span class="text-[#333]">${entry[1]}</span>
|
||||
</div>
|
||||
@end
|
||||
</div>
|
||||
</div>
|
||||
@end
|
||||
@end
|
||||
</section>
|
||||
@end
|
||||
</template>
|
||||
|
||||
<output>
|
||||
<!-- Rendered <template> goes here -->
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,69 +0,0 @@
|
||||
<style>
|
||||
#demo-server-info {
|
||||
padding: 8px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 64px;
|
||||
width: 100vw;
|
||||
background-color: whitesmoke;
|
||||
border-top: 2px solid gray;
|
||||
overflow: hide;
|
||||
margin: 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
#demo-server-info.show {
|
||||
max-height: 45vh;
|
||||
height: 500px;
|
||||
overflow: scroll;
|
||||
}
|
||||
#demo-activity {
|
||||
height:300px
|
||||
}
|
||||
|
||||
#demo-activity div {
|
||||
vertical-align: top
|
||||
}
|
||||
|
||||
#demo-activity ol li {
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
#demo-canvas {
|
||||
margin-bottom: 500px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#demo-server-info {
|
||||
background-color: var(--footerBackground);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
function toggleRequestInfo() {
|
||||
var classList = document.getElementById("demo-server-info").classList;
|
||||
classList.toggle("show");
|
||||
if (classList.contains('show')) {
|
||||
document.getElementById("request-info-toggler").innerHTML = "↓ Hide"
|
||||
} else {
|
||||
document.getElementById("request-info-toggler").innerHTML = "↑ Show"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<div id="demo-server-info">
|
||||
<div>Server Requests<span id="request-count"></span> <a id="request-info-toggler" onclick="toggleRequestInfo()" style="cursor: pointer">↑ Show</a></div>
|
||||
<div id="demo-activity" class="row">
|
||||
<div class="3 col" >
|
||||
<ol id="demo-timeline" reversed>
|
||||
</ol>
|
||||
</div>
|
||||
<div id="demo-current-request" class="9 col">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><a class="zola-anchor" href="#demo" aria-label="Anchor link for: demo">🔗</a>Demo</h2>
|
||||
<div id="demo-canvas">
|
||||
</div>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -24,15 +24,15 @@
|
||||
<link rel="stylesheet" href="/css/os9.css">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<script src="/js/htmx.js"></script>
|
||||
<script src="/js/class-tools.js"></script>
|
||||
<script src="/js/preload.js"></script>
|
||||
<!-- <script src="/js/class-tools.js"></script>-->
|
||||
<!-- <script src="/js/preload.js"></script>-->
|
||||
|
||||
<script src="/js/_hyperscript.js"></script>
|
||||
<meta name="generator" content="Zola v.TODO">
|
||||
</head>
|
||||
<body hx-ext="class-tools, preload">
|
||||
<header class="site-header">
|
||||
{% set should_boost = not page or not page.path is starting_with("/examples") %}
|
||||
{% set should_boost = not page or not page.path is starting_with("/patterns") %}
|
||||
<nav hx-boost="{{should_boost}}">
|
||||
<ul>
|
||||
<li>
|
||||
@ -42,7 +42,7 @@
|
||||
<ul preload="mouseover" id="nav-menu">
|
||||
<li><a href="/docs/">docs</a></li>
|
||||
<li><a href="/reference/">reference</a></li>
|
||||
<li><a href="/examples/">examples</a></li>
|
||||
<li><a href="/patterns/">patterns</a></li>
|
||||
<li><a href="/help/">help</a></li>
|
||||
<li><a href="/essays/">essays</a></li>
|
||||
<li><a href="https://hypermedia.systems">book</a></li>
|
||||
@ -92,7 +92,7 @@
|
||||
<div class="6 col footer-menu">
|
||||
<div><a href="/docs/">docs</a></div>
|
||||
<div><a href="/reference/">reference</a></div>
|
||||
<div><a href="/examples/">examples</a></div>
|
||||
<div><a href="/patterns/">patterns</a></div>
|
||||
<div><a href="/talk/">talk</a></div>
|
||||
<div><a href="/essays/">essays</a></div>
|
||||
<div><a href="https://twitter.com/htmx_org">@htmx_org</a></div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user