mirror of
https://github.com/bigskysoftware/hypermedia-systems.git
synced 2025-12-01 00:02:56 -05:00
Chapter 5 work
This commit is contained in:
parent
af0df21200
commit
7428c2ecd4
BIN
images/screenshot_stacked_actions.png
Normal file
BIN
images/screenshot_stacked_actions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
images/screenshot_total_contacts.png
Normal file
BIN
images/screenshot_total_contacts.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@ -892,6 +892,18 @@ that has just such a button in it:
|
||||
Believe it or not, that's all we need to change to enable a "Click To Load" style UI! No server side changes are necessary
|
||||
because of the flexibility that htmx gives you with respect to how we process server responses. Pretty cool, eh?
|
||||
|
||||
==== Relative Positional Targets
|
||||
|
||||
Here we saw the first example of a target that was "relatively positioned": `closest tr`. The `closest` keyword indicates
|
||||
that the closest parent that matches the following CSS selector is the target. So in this example the target was the
|
||||
`tr` that was enclosing the button.
|
||||
|
||||
htmx also supports `next` and `previous` relative positional expressions, allowing you to target the next element or
|
||||
previous element that matches a given CSS selector.
|
||||
|
||||
Relative positional expressions like this are quite powerful and allow you to avoid having to generate `id` attributes
|
||||
in your HTML just so you can target a particular element.
|
||||
|
||||
=== Infinite Scroll
|
||||
|
||||
Another somewhat common pattern for dealing with long lists of things is known as "infinite scroll", where,
|
||||
|
||||
@ -393,9 +393,480 @@ anything as an indicator and it will be hidden by default, and will be shown whe
|
||||
done via standard CSS classes, allowing you to control the transitions and even the mechanism by which the indicator
|
||||
is show (e.g. you might use `display` rather than `opacity`). htmx is flexible in this regard.
|
||||
|
||||
.Use Request Indicators!
|
||||
****
|
||||
Request indicators are an important UX aspect of any distributed application. It is unfortunate that browsers have
|
||||
de-emphasized their native request indicators over time, and it is doubly unfortunate that request indicators are not
|
||||
part of the JavaScript ajax APIs.
|
||||
|
||||
Be sure not to neglect this important aspect of your application! Even though requests might seem instant when you are
|
||||
working on your application locally, in the real world
|
||||
****
|
||||
|
||||
So there we go: we now have a pretty darned sophisticated user experience built out when compared with plain HTML, but
|
||||
we've built it all as a hypermedia-driven feature, no JSON or JavaScript to be seen! This particular implementation also
|
||||
has the benefit of being a progressive enhancement, so this aspect of our application will continue to work for clients
|
||||
that don't have JavaScript enabled. Pretty slick!
|
||||
|
||||
== Lazy Loading
|
||||
|
||||
With active search behind us, let's move on to a very different sort of problem, that of lazy loading. Lazy loading is
|
||||
when the loading of something is deferred until later, when needed. This is commonly used as a performance enhancement:
|
||||
you avoid the processing resources necessary to produce some data until that data is actually needed.
|
||||
|
||||
Let's add a count of the total number of contacts below the bottom of our contacts table. This will give us a potentially
|
||||
expensive operation that we can use to demonstrate how easy it is to add lazy loading to our application using htmx.
|
||||
|
||||
First let's update our server code in the `/contacts` request handler to get a count of the total number of contacts.
|
||||
We will pass that count through to the template to render some new HTML.
|
||||
|
||||
.Adding A Count To The UI
|
||||
[source,python]
|
||||
----
|
||||
@app.route("/contacts")
|
||||
def contacts():
|
||||
search = request.args.get("q")
|
||||
page = int(request.args.get("page", 1))
|
||||
count = Contact.count() <1>
|
||||
if search:
|
||||
contacts_set = Contact.search(search)
|
||||
if request.headers.get('HX-Trigger') == 'search':
|
||||
return render_template("rows.html", contacts=contacts_set, page=page, count=count)
|
||||
else:
|
||||
contacts_set = Contact.all(page)
|
||||
return render_template("index.html", contacts=contacts_set, page=page, count=count) <2>
|
||||
----
|
||||
<1> Get the total count of contacts from the Contact model
|
||||
<2> Pass the count out to the `index.html` template to use when rendering
|
||||
|
||||
Now, as with the rest of the application, we are not going to focus on the internals of `Contact.count()`, we are just
|
||||
going to take it for granted that:
|
||||
|
||||
* It returns the total count of contacts in the contact database
|
||||
* It may potentially be slow
|
||||
|
||||
Next lets add some HTML to our `index.html` that takes advantage of this new bit of data, showing a message next
|
||||
to the "Add Contact" link with the total count of users. Here is what our HTML looks like:
|
||||
|
||||
.Adding A Contact Count Element To The Application
|
||||
[source, html]
|
||||
----
|
||||
<p>
|
||||
<a href="/contacts/new">Add Contact</a> <span>({{ count }} total Contacts)</span><1>
|
||||
</p>
|
||||
----
|
||||
<1> A simple span with some text showing the total number of contacts.
|
||||
|
||||
Well that was easy, wasn't it? Now our users will see the total number of contacts next to the link to add new
|
||||
contacts, to give them a sense of how large the contact database is. This sort of rapid development is one of the
|
||||
joys of developing web applications the old way.
|
||||
|
||||
Here is what the feature looks like in our application:
|
||||
|
||||
[#figure-5-1, reftext="Figure {chapter}.{counter:figure}"]
|
||||
.Total Contact Count Display
|
||||
image::../images/screenshot_total_contacts.png[]
|
||||
|
||||
Beautiful.
|
||||
|
||||
Of course, as you probably suspected, all it not perfect. Unfortunately, upon shipping this feature to production, we
|
||||
start getting some complaints from the users that the application "feels slow". So, like all good developers faced with
|
||||
a performance issues, rather than guessing what the issue might be, we try to get a performance profile of the application
|
||||
to see what exactly is causing the problem.
|
||||
|
||||
It turns out, surprisingly, that the problem is that innocent looking `Contacts.count()` call, which is taking up to
|
||||
a second and a half to complete. Unfortunately, for reasons beyond the scope of this book, it is not possible to improve
|
||||
that load time, nor it is also not possible to cache the result. That leaves us with two choices:
|
||||
|
||||
* Remove the feature
|
||||
* Come up with some other way to mitigate the performance issue
|
||||
|
||||
After talking with the project manager, it is clear that removing the feature isn't an acceptable solution, so we
|
||||
will need to take another approach to mitigating the performance issue. And the approach we will use is lazy loading,
|
||||
where we defer loading the count "until later". Let's look at exactly how we can accomplish this using htmx.
|
||||
|
||||
=== Pulling The Expensive Code Out
|
||||
|
||||
The first step is to pull the expensive code, that is, the call to `Contacts.count()` out of request handerl for the
|
||||
`/contacts` end point.
|
||||
|
||||
Let's move it to a new end point at `/contacts/count` instead. We won't need to render a template with this new end
|
||||
point. It's only job is going to be to render that small bit of text that is in the span, "(22 total Contacts)"
|
||||
|
||||
Here is what the new code will look like:
|
||||
|
||||
.Pulling The Expensive Code Out
|
||||
[source,python]
|
||||
----
|
||||
@app.route("/contacts")
|
||||
def contacts():
|
||||
search = request.args.get("q")
|
||||
page = int(request.args.get("page", 1)) <1>
|
||||
if search:
|
||||
contacts_set = Contact.search(search)
|
||||
if request.headers.get('HX-Trigger') == 'search':
|
||||
return render_template("rows.html", contacts=contacts_set, page=page)
|
||||
else:
|
||||
contacts_set = Contact.all(page)
|
||||
return render_template("index.html", contacts=contacts_set, page=page) <2>
|
||||
|
||||
@app.route("/contacts/count")
|
||||
def contacts_count():
|
||||
count = Contact.count() <3>
|
||||
return "(" + str(count) + " total Contacts)" <4>
|
||||
|
||||
----
|
||||
<1> We no longer call `Contacts.count()` in this handler
|
||||
<2> `count` is no longer passed out to the template to render in the `/contacts` handler
|
||||
<3> We create a new handler at the `/contacts/count` path that does the expensive calculation
|
||||
<4> Return the string with the total number of contacts in it
|
||||
|
||||
Great! Now we have an end point that will produce that expensive-to-create text for us. The next step is to hook up
|
||||
the span we have created to actually retrieve this text. As we said earlier, the default behavior of htmx is to
|
||||
place any content it recieves for a given request into the `innerHTML` of an element, which is exactly what we want here:
|
||||
we want to retrieve this text and put it into the `span`. We can use the `hx-get` attribute pointing to this new path
|
||||
to do exactly that.
|
||||
|
||||
However, recall that the default _event_ that will trigger a request for a `span` element in htmx is the `click` event.
|
||||
That's not what we want. Instead, we want this to trigger passively, when the page loads. To do this, we can use
|
||||
the `hx-trigger` attribute with the value `load`. This will cause htmx to issue the request when the element is loaded
|
||||
into the page.
|
||||
|
||||
Here is our updated template code:
|
||||
|
||||
.Adding A Contact Count Element To The Application
|
||||
[source, html]
|
||||
----
|
||||
<p>
|
||||
<a href="/contacts/new">Add Contact</a> <span hx-get="/contacts/count" hx-trigger="load"</span><1>
|
||||
</p>
|
||||
----
|
||||
<1> Issue a `GET` to `/contacts/count` when the `load` event occurs
|
||||
|
||||
Note that the `span` starts empty: we have removed the content from it, and we are allowing the request to `/contacts/count`
|
||||
to populate it instead.
|
||||
|
||||
And, lo and behold, our `/contacts` page is fast again! When you navigate to the page it feels very snappily and
|
||||
profiling shows that yes, indeed, the page is loading much more quickly. Why is that? We'll we've deferred the
|
||||
expensive calculation to a secondary request, allowing the initial request to finish loading much more quickly.
|
||||
|
||||
You might say "OK, great, but it's still taking a second or two to get the total count on the page." That's true, but
|
||||
often the user may not be particularly interested in the total count. They may just want to come to the page and
|
||||
search for an existing user, or perhaps they may want to edit or add a user. The total count
|
||||
is often just a "nice to have" bit of information in these cases. By deferring the calculation of the count in this manner
|
||||
we let users get on with their use of the application while we perform the expensive calculation.
|
||||
|
||||
Yes, the total time to get all the information on the screen takes just as long. (It actually might be a bit longer since
|
||||
we now have two requests that need to get all the information.) But the _percieved performance_ for the end user will
|
||||
be much better: they can do what they want nearly immediately, even if some information isn't available instantaneously.
|
||||
This is a great tool to have in your toolbelt when optimizing your web application performance!
|
||||
|
||||
=== Adding An Indicator
|
||||
|
||||
Unfortunately there is one somewhat disconcerting aspect to our current implementation: the count is lazily loaded,
|
||||
but there is no way for a user to know that this computation is being done. As it stands, the count just sort of
|
||||
bursts onto the scene whenever the request to `/contacts/count` completes.
|
||||
|
||||
That's not ideal. What we want is an indicator, like we added to our active search example. And, in fact, we can
|
||||
simply reuse the same spinner image here!
|
||||
|
||||
Now, in this case, we have a one-time request and nce the request is over, we are not going to need the spinner anymore.
|
||||
So it doesn't make sense to use the exact approach we did with the active search example, placing a spinner beside the
|
||||
span and using the `hx-indicator` attribute to point to it.
|
||||
|
||||
Instead, we can put the spinner _inside_ the content of the span. When the request completes the content in the response
|
||||
will be placed inside the span, replacing the spinner with the computed contact count. It turns out that htmx allows
|
||||
you to place indicators with the `htmx-indicator` class on them inside of elements that are htmx-powered and, in the
|
||||
absence of an `hx-indicator` attribute, these internal indicators will be shown when a request is in flight.
|
||||
|
||||
So let's add that spinner from the active search example as the initial content in our span:
|
||||
|
||||
.Adding An Indicator To Our Lazily Loaded Content
|
||||
[source, html]
|
||||
----
|
||||
<span hx-get="/contacts/count" hx-trigger="load">
|
||||
<img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg"/><1>
|
||||
</span>
|
||||
----
|
||||
<1> Yep, that's it
|
||||
|
||||
Great! Now when the user loads the page, rather than having the total contact count sprung on them like a surprise,
|
||||
there is a nice spinner indicating that something is coming. Much better!
|
||||
|
||||
Note that all we had to do was copy and paste our indicator from the active search example into the `span`! This is
|
||||
a great demonstration of how htmx provides flexible, composable features and building blocks to work with: implementing
|
||||
a new feature is often just a copy-and-paste, with maybe a tweak or two, and you are done.
|
||||
|
||||
=== But That's Not Lazy!
|
||||
|
||||
You might say "OK, but that's not really lazy. We are still loading the count immediately when the page is loaded,
|
||||
we are just doing it in a second request. You aren't really waiting until the value is actually needed."
|
||||
|
||||
Fine. Let's make it _lazy_ lazy: we'll only issue the request when the span scrolls into view.
|
||||
|
||||
To do that, just recall how we set up the infinite scroll example: we used the `revealed` event for our trigger. That's
|
||||
all we want here, right? When the element is revealed we issue the request.
|
||||
|
||||
.Making It Lazy Lazy
|
||||
[source, html]
|
||||
----
|
||||
<span hx-get="/contacts/count" hx-trigger="revealed"> <1>
|
||||
<img id="spinner" class="htmx-indicator" src="/static/img/spinning-circles.svg"/>
|
||||
</span>
|
||||
----
|
||||
<1> Yep, that's it
|
||||
|
||||
Now we have a truly lazy implementation, deferring the expensive computation until we are absolutely sure we need it. A
|
||||
pretty cool trick, and, again, a simple one-attribute change demonstrates the flexibility of both htmx the hypermedia
|
||||
approach.
|
||||
|
||||
== Inline Delete
|
||||
|
||||
We now have some pretty slick UX patterns in our application, but let's not rest on our laurels. For our next
|
||||
hypermedia trick, we are going to implement "inline delete", where a contact can be deleted directly from the
|
||||
list view of all contacts, rather than requiring the user to drill in to the edit view of particular contact to access
|
||||
the "Delete Contact" button.
|
||||
|
||||
We already have "Edit" and "View" links for each row, in the `rows.html` template:
|
||||
|
||||
.The Existing Row Actions
|
||||
[source, html]
|
||||
----
|
||||
<td>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
</td>
|
||||
----
|
||||
|
||||
We want to add a "Delete" link as well. And we want that link to act an awful lot like the "Delete Contact" from
|
||||
`edit.html`, don't we? We'd like to issue an HTTP `DELETE` to the URL for the given contact, we want a confirmation
|
||||
dialog to ensure the user doesn't accidentally delete a contact. Here is the "Delete Contact" html:
|
||||
|
||||
.The Existing Row Actions
|
||||
[source, html]
|
||||
----
|
||||
<button hx-delete="/contacts/{{ contact.id }}"
|
||||
hx-push-url="true"
|
||||
hx-confirm="Are you sure you want to delete this contact?"
|
||||
hx-target="body">
|
||||
Delete Contact
|
||||
</button>
|
||||
----
|
||||
|
||||
Is this going to be another copy-and-paste job with a bit of tweaking? It sure is!
|
||||
|
||||
One thing to note is that, in the case of the "Delete Contact" button, we want to rerender the whole screen and update
|
||||
the URL, since we are going to be returning from the edit view for the contact to the list view of all contacts. In
|
||||
the case of this link, however, we are already on the list of contacts, so there is no need to update the URL, and
|
||||
we can omit the `hx-push-url` attribute.
|
||||
|
||||
Here is our updated code:
|
||||
|
||||
.The Existing Row Actions
|
||||
[source, html]
|
||||
----
|
||||
<td>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
<a href="#" hx-delete="/contacts/{{ contact.id }}"
|
||||
hx-confirm="Are you sure you want to delete this contact?"
|
||||
hx-target="body">Delete</a> <1>
|
||||
</td>
|
||||
----
|
||||
<1> Almost a straight copy of the "Delete Contact" button
|
||||
|
||||
As you can see, we have added a new anchor tag and given it a blank target (the `#` value in its `href` attribute) to
|
||||
retain the correct mouse-over styling behavior of the link. We've also copied the `hx-delete`, `hx-confirm` and
|
||||
`hx-target` attributes from the "Delete Contact" button, but omitted the `hx-push-url` attributes since we don't want
|
||||
to update the URL of the browser.
|
||||
|
||||
And... that's it! We now have inline delete working, even with a confirmation dialog!
|
||||
|
||||
. A Style Sidebar
|
||||
****
|
||||
One thing is really starting to bother me about our application: we now have quite a few actions stacking up in our
|
||||
contacts table, and it is starting to look very distracting:
|
||||
|
||||
[#figure-5-1, reftext="Figure {chapter}.{counter:figure}"]
|
||||
.That's a Lot of Actions
|
||||
image::../images/screenshot_stacked_actions.png[]
|
||||
|
||||
It would be nice if we didn't show the actions all in a row, and it would be nice if we only showed the actions when
|
||||
the user indicated interest in a given row. We will return to this problem after we look at the relationship between
|
||||
scripting and a Hypermedia Driven Application in a later chapter.
|
||||
|
||||
For now, let's just tolerate this less-than-ideal user interface, knowing that we will return to it later.
|
||||
****
|
||||
|
||||
=== Getting Fancy
|
||||
|
||||
We can get even fancier here, however. What if, rather than re-rendering the whole page, we just removed the row
|
||||
for the contact? The user is looking at the row anyway, so is there really a need to re-render the whole page?
|
||||
|
||||
To do this, we'll need to do a couple of things:
|
||||
|
||||
* We'll need to update this link to target the row that it is in
|
||||
* We'll need to change the swap to `outerHTML`, since we want to replace (really, remove) the entire row
|
||||
* We'll need to update the server side to render empty content when the `DELETE` is issued from a row rather
|
||||
than from the "Delete Contact" button on the contact edit page
|
||||
|
||||
First things first, update the target of our "Delete" link to be the row that the link is in, rather than the entire
|
||||
body. We can once again take advantage of the relative positional `closest` feature to target the closest `tr`, like
|
||||
we did in our "Click To Load" and "Infinite Scroll" features:
|
||||
|
||||
.The Existing Row Actions
|
||||
[source, html]
|
||||
----
|
||||
<td>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
<a href="#" hx-delete="/contacts/{{ contact.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this contact?"
|
||||
hx-target="closest tr">Delete</a> <1>
|
||||
</td>
|
||||
----
|
||||
<1> Updated to target the closest enclosing `tr` (table row) of the link
|
||||
|
||||
Now we need to update the server side as well. We want to keep the "Delete Contact" button working as well, and in
|
||||
that case the current logic is correct. So we'll need some way to differentiate between `DELETE` requests that are
|
||||
triggered by the button and `DELETE` requests that come from this anchor.
|
||||
|
||||
The cleanest way to do this is to add an `id` attribute to the "Delete Contact" button, so that we can inspect the
|
||||
`HX-Trigger` HTTP Request header to determine if the delete button was the cause of the request. This is a simple
|
||||
change to the existing HTML:
|
||||
|
||||
.Adding an `id` to the "Delete Contact" button
|
||||
[source, html]
|
||||
----
|
||||
<button id="delete-btn" <1>
|
||||
hx-delete="/contacts/{{ contact.id }}"
|
||||
hx-push-url="true"
|
||||
hx-confirm="Are you sure you want to delete this contact?"
|
||||
hx-target="body">
|
||||
Delete Contact
|
||||
</button>
|
||||
----
|
||||
<1> An `id` attribute has been added to the button
|
||||
|
||||
With this in place, we now have a mechanism for differentiating between the delete button in the `edit.html` template and
|
||||
the delete links in the `rows.html` template. We can write code very similar to what we did for the active search pattern,
|
||||
using a conditional on the `HX-Trigger` header to determine what we want to do. If that header has the value `delete-btn`,
|
||||
then we know the request came from the button on the edit page, and we can do what we are currently doing: delete the
|
||||
contact and redirect to `/contacts` page.
|
||||
|
||||
If it does not have that value, then we can simple delete the contact and return an empty string. This empty string
|
||||
will replace the target, in this case the row for the given contact, thereby removing the row from the UI.
|
||||
|
||||
Let's make that change to our server side code:
|
||||
|
||||
.Updating Our Server Code To Handle Two Different Delete Patterns
|
||||
[source, python]
|
||||
----
|
||||
@app.route("/contacts/<contact_id>", methods=["DELETE"])
|
||||
def contacts_delete(contact_id=0):
|
||||
contact = Contact.find(contact_id)
|
||||
contact.delete()
|
||||
if request.headers.get('HX-Trigger') == 'delete-brn': <1>
|
||||
flash("Deleted Contact!")
|
||||
return redirect("/contacts", 303)
|
||||
else:
|
||||
return "" <2>
|
||||
----
|
||||
<1> If the delete button on the edit page submitted this request, then continue to do the logic we had previous
|
||||
<2> If not, simply return an empty string, which will delete the row
|
||||
|
||||
Believe it or not, we are now done: when a user clicks "Delete" on a contact row and confirms the delete, the row will
|
||||
disappear from the UI. Poof! Once again, we have a situation where just changing a few lines of simple code gives us a
|
||||
dramatically different behavior. Hypermedia is very powerful!
|
||||
|
||||
=== Getting Fancy Fancy With The htmx Swapping Model
|
||||
|
||||
This is pretty cool, but there is another improvement we can make if we take some time to understand the htmx content
|
||||
swapping model: it sure would be sexy if, rather than just instantly deleting the row, we faded it out before we removed
|
||||
it. That easement makes it more obvious that the row is being removed, giving the user some nice visual feedback on the
|
||||
modification.
|
||||
|
||||
It turns out we can do this pretty easily with htmx, but to do so we'll need to dig in to exactly how htmx swaps content.
|
||||
You might think that htmx simply puts the new content into the DOM, but that's not how it works. Instead, content goes
|
||||
through a series of steps as it is added to the DOM:
|
||||
|
||||
* When content is recieved and about to be swapped into the DOM, the `htmx-swapping` CSS class is added to the target
|
||||
element
|
||||
* A small delay then occurs (we will discuss why in a moment)
|
||||
* Next, the `htmx-swapping` class is removed from the target and the `htmx-settling` class is added
|
||||
* The new content is swapped into the DOM
|
||||
* Another small delay occurs
|
||||
* Finally, the `htmx-settling` class is removed from the target
|
||||
|
||||
There is more to the swap mechanic, settling for example is a more advanced topic that we will discuss in a later chapter,
|
||||
but for now this is all you need to know about the swapping mechanism. Now, there are small delays in the process here,
|
||||
typically on the order of a few milliseconds. Why so?
|
||||
|
||||
It turns out that these small delays allow _CSS transitions_ to occur.
|
||||
|
||||
CSS transitions are a technology that allow you to animate a transition from one style to another. So, for example, if
|
||||
you changed the height of something from 10 pixels to 20 pixels, by using a CSS transition you can make the element
|
||||
smoothly animate to the new height. These sorts of animations are fun, often increase application usability, and are ::
|
||||
great mechanism to add polish and fit-and-finish to your web application.
|
||||
|
||||
Unfortunately, CSS transitions are not available in plain HTML: you have to use JavaScript and add or remove classes
|
||||
to get them to trigger. This is exactly why the htmx swap model is more complicated than you might think: by swapping
|
||||
in classes and adding small delays, you can access CSS transitions purely from hypermedia.
|
||||
|
||||
=== Taking Advantage of `htmx-swapping`
|
||||
|
||||
OK, so, let's go back and look at our inline delete mechanic: we click an htmx enhanced link which deletes the contact
|
||||
and then swaps some empty content in for the row. We know that, before the `tr` element is removed, it will have the
|
||||
`htmx-swapping` class added to it. We can take advantage of that to write a CSS transition that fades the opacity of
|
||||
the row to 0. Here is what that CSS looks like:
|
||||
|
||||
.Adding A Fade Out Transition
|
||||
[source, css]
|
||||
----
|
||||
tr.htmx-swapping { <1>
|
||||
opacity: 0; <2>
|
||||
transition: opacity 1s ease-out; <3>
|
||||
}
|
||||
----
|
||||
<1> We want this style to apply to `tr` elements with the `htmx-swapping` class on them
|
||||
<2> The `opacity` will be 0, making it invisible
|
||||
<3> The `opacity` will transition to 0 over a 1 second time period, using the `ease-out` function
|
||||
|
||||
Again, this is not a CSS book and I am not going to go deeply into the details of CSS transitions, but hopefully the
|
||||
above makes sense to you, even if this is the first time you've seen CSS transitions.
|
||||
|
||||
So, think about what this means from the htmx swapping model: when htmx gets content back to swap into the row it will
|
||||
put the `htmx-swapping` class on the row and wait a bit. This will allow the transition to a zero opacity to occur,
|
||||
fading the row out. Then the new (empty) content will be swapped in, which will effectively removing the row.
|
||||
|
||||
Sounds good, and we are nearly there. There is one more thing we need to do: the default "swap delay" for htmx is very
|
||||
short, a few milliseconds. That makes sense in most cases: you don't want to have much of a delay before you put the
|
||||
new content into the DOM. But, in this case, we want to give the CSS animation time to complete before we do the swap,
|
||||
we want to give it a second, in fact.
|
||||
|
||||
Fortunately htmx has an option for the `hx-swap` annotation that allows you to set the swap delay: following the swap
|
||||
type you can add `swap:` followed by a timing value to tell htmx to wait a specific amount of time before it swaps. Let's
|
||||
update our HTML to allow a one second delay before the swap is done for the delete action:
|
||||
|
||||
.The Existing Row Actions
|
||||
[source, html]
|
||||
----
|
||||
<td>
|
||||
<a href="/contacts/{{ contact.id }}/edit">Edit</a>
|
||||
<a href="/contacts/{{ contact.id }}">View</a>
|
||||
<a href="#" hx-delete="/contacts/{{ contact.id }}"
|
||||
hx-swap="outerHTML swap:1s" <1>
|
||||
hx-confirm="Are you sure you want to delete this contact?"
|
||||
hx-target="closest tr">Delete</a>
|
||||
</td>
|
||||
----
|
||||
<1> A swap delay changes how long htmx waits before it swaps in new content
|
||||
|
||||
With this modification, the existing row will stay in the DOM for an additional second, with the `htmx-swapping` class
|
||||
on it. This will give the row time to transition to an opacity of zero, giving the fade out effect we want.
|
||||
|
||||
Now, when a user clicks on a "Delete" link and confirms the delete, the row will slowly fade out and then, once it has
|
||||
faded to a 0 opacity, it will be removed. Fancy! And all done in a declarative, hypermedia oriented manner, no
|
||||
JavaScript required! (Well, obviously htmx is written in JavaScript, but you know what I mean: we didn't have to write
|
||||
any JavaScript to implement the feature!)
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||
/ModDate (D:20220615142625-06'00')
|
||||
/CreationDate (D:20220718164528-06'00')
|
||||
/CreationDate (D:20220722135105-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||
/ModDate (D:20220713173445-06'00')
|
||||
/CreationDate (D:20220718164535-06'00')
|
||||
/CreationDate (D:20220722135111-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
/Creator (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||
/Producer (Asciidoctor PDF 1.6.2, based on Prawn 2.4.0)
|
||||
/ModDate (D:20220711173725-06'00')
|
||||
/CreationDate (D:20220718164536-06'00')
|
||||
/CreationDate (D:20220722135112-06'00')
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user