├── .github
├── FUNDING.yml
└── workflows
│ └── go.yml
├── .gitignore
├── LICENSE
├── README.md
├── example
└── main.go
├── external.go
├── go.mod
├── go.sum
├── header.go
├── request.go
├── respheaders.go
├── response.go
├── response_test.go
├── swap.go
└── swap_test.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: angelofallars
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/.github/workflows/go.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a golang project
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
3 |
4 | name: Go
5 |
6 | on:
7 | push:
8 | branches: [ "main" ]
9 | pull_request:
10 | branches: [ "main" ]
11 |
12 | jobs:
13 |
14 | build:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Set up Go
20 | uses: actions/setup-go@v4
21 | with:
22 | go-version: '1.21.4'
23 |
24 | - name: Build
25 | run: go build -v ./...
26 |
27 | - name: Test
28 | run: go test -v ./...
29 |
30 | - name: Lint
31 | uses: golangci/golangci-lint-action@v3
32 | with:
33 | version: latest
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries
2 | /bin/
3 | __debug*
4 |
5 | # Temporary
6 | tmp/
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Bien Angelo Fallaria
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
2 |
3 | [](https://pkg.go.dev/github.com/angelofallars/htmx-go?tab=doc)
4 | [](https://github.com/angelofallars/htmx-go/actions)
5 | [](./LICENSE)
6 | [](https://github.com/angelofallars/htmx-go/stargazers)
7 | [](https://htmx.org/discord)
8 |
9 | htmx-go is a **type-safe** library for working
10 | with [HTMX](https://htmx.org/) in Go.
11 |
12 | Less time fiddling with HTTP
13 | headers, more time developing awesome Hypermedia-driven applications.
14 |
15 | Check if requests are from HTMX, and use a type-safe, declarative
16 | syntax for HTMX response headers to control HTMX behavior from the server.
17 |
18 | Write [triggers](#triggers) for client-side events effectively without dealing with JSON serialization. With this approach, **event-driven** applications are easier to develop.
19 |
20 | Use [Swap Strategy](#swap-strategy) methods to fine-tune `hx-swap` behavior.
21 |
22 | Uses standard `net/http` types.
23 | Has basic [integration](#templ-integration) with [templ](https://templ.guide/) components.
24 |
25 | ```go
26 | import (
27 | "net/http"
28 |
29 | "github.com/angelofallars/htmx-go"
30 | )
31 |
32 | func handler(w http.ResponseWriter, r *http.Request) {
33 | if htmx.IsHTMX(r) {
34 | htmx.NewResponse().
35 | Reswap(htmx.SwapBeforeEnd).
36 | Retarget("#contacts").
37 | AddTrigger(htmx.Trigger("enable-submit")).
38 | AddTrigger(htmx.TriggerDetail("display-message", "Hello world!")).
39 | Write(w)
40 | }
41 | }
42 | ```
43 |
44 | > Think this project is awesome? [Consider sponsoring me](https://github.com/sponsors/angelofallars) 💙
45 |
46 | ## Installation
47 |
48 | Use go get.
49 |
50 | ```sh
51 | go get github.com/angelofallars/htmx-go
52 | ```
53 |
54 | Then import htmx-go:
55 |
56 | ```go
57 | import "github.com/angelofallars/htmx-go"
58 | ```
59 |
60 | ## HTMX Requests
61 |
62 | ### Check request origin
63 |
64 | You can determine if a request is from HTMX.
65 | With this, you can add custom handling for non-HTMX requests.
66 |
67 | You can also use this for checking if this is a GET request for the initial
68 | page load on your website, as initial page load requests
69 | don't use HTMX.
70 |
71 | ```go
72 | func handler(w http.ResponseWriter, r *http.Request) {
73 | if htmx.IsHTMX(r) {
74 | // logic for handling HTMX requests
75 | } else {
76 | // logic for handling non-HTMX requests (e.g. render a full page for first-time visitors)
77 | }
78 | }
79 | ```
80 |
81 | ### Check if request is Boosted (`hx-boost`)
82 |
83 | ```go
84 | func handler(w http.ResponseWriter, r *http.Request) {
85 | if htmx.IsBoosted(r) {
86 | // logic for handling boosted requests
87 | } else {
88 | // logic for handling non-boosted requests
89 | }
90 | }
91 | ```
92 |
93 | ## HTMX responses
94 |
95 | htmx-go takes inspiration from [Lip Gloss](https://github.com/charmbracelet/lipgloss)
96 | for a declarative way of specifying HTMX response headers.
97 |
98 | ### Basic usage
99 |
100 | Make a response writer with `htmx.NewResponse()`, and add a
101 | header to it to make the page refresh:
102 |
103 | ``` go
104 | func handler(w http.ResponseWriter, r *http.Request) {
105 | writer := htmx.NewResponse().Refresh(true)
106 | writer.Write(w)
107 | }
108 | ```
109 |
110 | ### Retarget response to a different element
111 |
112 | ```go
113 | func handler(w http.ResponseWriter, r *http.Request) {
114 | htmx.NewResponse().
115 | // Override 'hx-target' to specify which target to load into
116 | Retarget("#errors").
117 | // Also override the 'hx-swap' value of the request
118 | Reswap(htmx.SwapBeforeEnd).
119 | Write(w)
120 | }
121 | ```
122 |
123 | ### Triggers
124 |
125 | [HTMX Reference: `hx-trigger`](https://htmx.org/headers/hx-trigger/)
126 |
127 | You can add triggers to trigger client-side [events](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events). htmx-go takes care of formatting
128 | and JSON serialization of the header values.
129 |
130 | Define event triggers:
131 |
132 | - `htmx.Trigger(eventName string)` - A trigger with no details.
133 | - `htmx.TriggerDetail(eventName string, detailValue string)` - A trigger with one detail value.
134 | - `htmx.TriggerObject(eventName string, detailObject any)` - A trigger with a JSON-serializable detail
135 | object. Recommended to pass in either `map[string]string` or structs with JSON field tags.
136 |
137 | Set trigger headers using the preceding triggers:
138 |
139 | - `Response.AddTrigger(trigger ...EventTrigger)` - appends to the `HX-Trigger` header
140 | - `Response.AddTriggerAfterSettle(trigger ...EventTrigger)` - appends to the `HX-Trigger-After-Settle` header
141 | - `Response.AddTriggerAfterSwap(trigger ...EventTrigger)` - appends to the `HX-Trigger-After-Swap` header
142 |
143 | ```go
144 | htmx.NewResponse().
145 | AddTrigger(htmx.Trigger("myEvent"))
146 | // HX-Trigger: myEvent
147 |
148 | htmx.NewResponse().
149 | AddTrigger(htmx.TriggerDetail("showMessage", "Here Is A Message"))
150 | // HX-Trigger: {"showMessage":"Here Is A Message"}
151 |
152 | htmx.NewResponse().
153 | AddTrigger(
154 | htmx.TriggerDetail("hello", "world"),
155 | htmx.TriggerObject("myEvent", map[string]string{
156 | "level": "info",
157 | "message": "Here Is A Message",
158 | }),
159 | )
160 | // HX-Trigger: {"hello":"world","myEvent":{"level":"info","message":"Here is a Message"}}
161 | ```
162 |
163 | > [!TIP]
164 | > [Alpine.js](https://alpinejs.dev/) and [Hyperscript](https://hyperscript.org) can listen to
165 | > and receive details from events triggered by htmx-go. This makes triggers initiated by the server
166 | > very handy for event-driven applications!
167 | >
168 | > For Alpine.js, you can register an `x-on:.window` listener. The `.window` modifier
169 | > is important because HTMX dispatches events from the root `window` object.
170 | > To receive values sent by `htmx.TriggerDetail` and `htmx.TriggerObject`,
171 | > you can use `$event.detail.value`.
172 |
173 | ### Swap strategy
174 |
175 | [HTMX Reference: `hx-swap`](https://htmx.org/attributes/hx-swap/)
176 |
177 | `Response.Reswap()` takes in `SwapStrategy` values from this library.
178 |
179 | ```go
180 | htmx.NewResponse().
181 | Reswap(htmx.SwapInnerHTML)
182 | // HX-Reswap: innerHTML
183 |
184 | htmx.NewResponse().
185 | Reswap(htmx.SwapAfterEnd.Transition(true))
186 | // HX-Reswap: innerHTML transition:true
187 | ```
188 |
189 | Exported `SwapStrategy` constant values can be appended with modifiers through their methods.
190 | If successive methods write to the same modifier,
191 | the modifier is always replaced with the latest one.
192 |
193 | ```go
194 | import "time"
195 |
196 | htmx.SwapInnerHTMl.After(time.Second * 1)
197 | // HX-Reswap: innerHTML swap:1s
198 |
199 | htmx.SwapBeforeEnd.Scroll(htmx.Bottom)
200 | // HX-Reswap: beforeend scroll:bottom
201 |
202 | htmx.SwapAfterEnd.IgnoreTitle(true)
203 | // HX-Reswap: afterend ignoreTitle:true
204 |
205 | htmx.SwapAfterEnd.FocusScroll(true)
206 | // HX-Reswap: afterend ignoreTitle:true
207 |
208 | htmx.SwapInnerHTML.ShowOn("#another-div", htmx.Top)
209 | // HX-Reswap: innerHTML show:#another-div:top
210 |
211 | // Modifier chaining
212 | htmx.SwapInnerHTML.ShowOn("#another-div", htmx.Top).After(time.Millisecond * 500)
213 | // HX-Reswap: innerHTML show:#another-div:top swap:500ms
214 |
215 | htmx.SwapBeforeBegin.ShowWindow(htmx.Top)
216 | // HX-Reswap: beforebegin show:window:top
217 |
218 | htmx.SwapDefault.ShowNone()
219 | // HX-Reswap: show:none
220 | ```
221 |
222 | ### Code organization
223 |
224 | HTMX response writers can be declared outside of functions with `var` so you can reuse them in several
225 | places.
226 |
227 | > [!CAUTION]
228 | > If you're adding additional headers to a global response writer, always use the `.Clone()` method
229 | > to avoid accidentally modifying the global response writer.
230 |
231 | ```go
232 | var deleter = htmx.NewResponse().
233 | Reswap(htmx.SwapDelete)
234 |
235 | func(w http.ResponseWriter, r *http.Request) {
236 | deleter.Clone().
237 | Reselect("#messages").
238 | Write(w)
239 | }
240 | ```
241 |
242 | ### Templ integration
243 |
244 | HTMX pairs well with [Templ](https://templ.guide), and this library is no exception.
245 | You can render both the necessary HTMX response headers and Templ components in
246 | one step with the `.RenderTempl()` method.
247 |
248 | ```go
249 | // hello.templ
250 | templ Hello() {
251 | Hello { name }!
252 | }
253 |
254 | // main.go
255 | func(w http.ResponseWriter, r *http.Request) {
256 | htmx.NewResponse().
257 | Retarget("#hello").
258 | RenderTempl(r.Context(), w, Hello())
259 | }
260 | ```
261 |
262 | > [!NOTE]
263 | > To avoid issues with custom HTTP status code headers with this approach,
264 | > it's recommended to use `Response().StatusCode()` so the status code header
265 | > is always set after the HTMX headers.
266 |
267 | ### Stop polling
268 |
269 | If you have an element that is polling a URL and you want it to stop, use the
270 | `htmx.StatusStopPolling` 286 status code in a response to cancel the polling. [HTMX documentation
271 | reference](https://htmx.org/docs/#polling)
272 |
273 | ```go
274 | w.WriteHeader(htmx.StatusStopPolling)
275 | ```
276 |
277 | ## Header names
278 |
279 | If you need to work with HTMX headers directly, htmx-go provides constant values for all
280 | HTTP header field names of HTMX so you don't have to write them yourself. This mitigates the risk of writing
281 | header names with typos.
282 |
283 | ```go
284 | // Request headers
285 | const (
286 | HeaderBoosted = "HX-Boosted"
287 | HeaderCurrentURL = "HX-Current-URL"
288 | HeaderHistoryRestoreRequest = "HX-History-Restore-Request"
289 | HeaderPrompt = "HX-Prompt"
290 | HeaderRequest = "HX-Request"
291 | HeaderTarget = "HX-Target"
292 | HeaderTriggerName = "Hx-Trigger-Name"
293 | )
294 |
295 | // Common headers
296 | const (
297 | HeaderTrigger = "HX-Trigger"
298 | )
299 |
300 | // Response headers
301 | const (
302 | HeaderLocation = "HX-Location"
303 | HeaderPushURL = "HX-Push-Url"
304 | HeaderRedirect = "HX-Redirect"
305 | HeaderRefresh = "HX-Refresh"
306 | HeaderReplaceUrl = "HX-Replace-Url"
307 | HeaderReswap = "HX-Reswap"
308 | HeaderRetarget = "HX-Retarget"
309 | HeaderReselect = "HX-Reselect"
310 | HeaderTriggerAfterSettle = "HX-Trigger-After-Settle"
311 | HeaderTriggerAfterSwap = "HX-Trigger-After-Swap"
312 | )
313 | ```
314 |
315 | ## Compatibility
316 |
317 | This library is compatible with the standard `net/http` library, as well as other routers like Chi
318 | and Gorilla Mux that use the standard `http.HandlerFunc` handler type.
319 |
320 | With the Echo web framework, try passing in `context.Request()` and
321 | `context.Response().Writer` for requests and responses, respectively.
322 |
323 | With the Gin web framework on the other hand, try using `context.Request` and
324 | `context.Writer`.
325 |
326 | If you use Fiber, it is recommended to use [`htmx-fiber`](https://github.com/sopa0/htmx-fiber) instead, which is a fork of htmx-go.
327 |
328 | ## Additional resources
329 |
330 | - [HTMX - HTTP Header Reference](https://htmx.org/reference/#headers)
331 |
332 | ## Contributing
333 |
334 | Pull requests are welcome!
335 |
336 | ## License
337 |
338 | [MIT](./LICENSE)
339 |
--------------------------------------------------------------------------------
/example/main.go:
--------------------------------------------------------------------------------
1 | // Just some basic example usage of the library
2 | package main
3 |
4 | import (
5 | "fmt"
6 | "net/http"
7 | "time"
8 |
9 | "github.com/angelofallars/htmx-go"
10 | )
11 |
12 | func main() {
13 | fmt.Println(htmx.SwapBeforeEnd.
14 | Scroll(htmx.Bottom).
15 | SettleAfter(time.Millisecond * 500),
16 | )
17 | r := htmx.NewResponse().
18 | Reswap(htmx.SwapAfterBegin.Scroll(htmx.Top)).
19 | AddTrigger(
20 | htmx.TriggerObject("hello", "world"),
21 | htmx.TriggerObject("myEvent", map[string]string{
22 | "level": "info",
23 | "message": "Here is a Message",
24 | }),
25 | )
26 |
27 | fmt.Println(r)
28 | fmt.Println(r.Headers())
29 | }
30 |
31 | func myHandler(w http.ResponseWriter, r *http.Request) {
32 | if !htmx.IsHTMX(r) {
33 | w.Write([]byte("only HTMX requests allowed"))
34 | w.WriteHeader(http.StatusBadRequest)
35 | return
36 | }
37 |
38 | writer := htmx.NewResponse().
39 | Reswap(htmx.SwapBeforeBegin).
40 | Redirect("/cats").
41 | LocationWithContext("/hello", htmx.LocationContext{
42 | Target: "#testdiv",
43 | Source: "HELLO",
44 | }).
45 | Refresh(false)
46 |
47 | writer.Write(w)
48 | }
49 |
--------------------------------------------------------------------------------
/external.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "context"
5 | "io"
6 | )
7 |
8 | // Interface to integrate with Templ components
9 | type templComponent interface {
10 | // Render the template.
11 | Render(ctx context.Context, w io.Writer) error
12 | }
13 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/angelofallars/htmx-go
2 |
3 | go 1.21.4
4 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/angelofallars/htmx-go/5a812c13d86518708cb2982b43787c19bca93ec0/go.sum
--------------------------------------------------------------------------------
/header.go:
--------------------------------------------------------------------------------
1 | // Package htmx provides utilities to build HTMX-powered web applications.
2 | package htmx
3 |
4 | // HTTP request headers
5 | const (
6 | // Request header that is "true" if the request was made from an element using 'hx-boost'.
7 | HeaderBoosted = "HX-Boosted"
8 | // Request header for the current URL of the browser.
9 | HeaderCurrentURL = "HX-Current-URL"
10 | // Request header that is “true” if the request is for history restoration after a miss in the local history cache.
11 | HeaderHistoryRestoreRequest = "HX-History-Restore-Request"
12 | // Request header for the user response to an hx-prompt.
13 | HeaderPrompt = "HX-Prompt"
14 | // Request header that is always “true” for HTMX requests.
15 | HeaderRequest = "HX-Request"
16 | // Request header of the id of the target element if it exists.
17 | HeaderTarget = "HX-Target"
18 | // Request header of the name of the triggered element if it exists.
19 | HeaderTriggerName = "Hx-Trigger-Name"
20 | )
21 |
22 | // Common HTTP headers
23 | const (
24 | // As a request header: The ID of the triggered element if it exists.
25 | //
26 | // As a response header: Allows you to trigger client-side events.
27 | HeaderTrigger = "HX-Trigger"
28 | )
29 |
30 | // HTTP response headers
31 | const (
32 | // Response header that allows you to do a client-side redirect that does not do a full page reload.
33 | HeaderLocation = "HX-Location"
34 | // Response header that pushes a new url into the history stack.
35 | HeaderPushURL = "HX-Push-Url"
36 | // Response header that can be used to do a client-side redirect to a new location.
37 | HeaderRedirect = "HX-Redirect"
38 | // Response header that if set to “true” the client-side will do a full refresh of the page.
39 | HeaderRefresh = "HX-Refresh"
40 | // Response header that replaces the current URL in the location bar.
41 | HeaderReplaceUrl = "HX-Replace-Url"
42 | // Response header that allows you to specify how the response will be swapped.
43 | HeaderReswap = "HX-Reswap"
44 | // Response header that uses a CSS selector that updates the target of the content update to a
45 | // different element on the page.
46 | HeaderRetarget = "HX-Retarget"
47 | // Response header that uses a CSS selector that allows you to choose which
48 | // part of the response is used to be swapped in. Overrides an existing hx-select
49 | // on the triggering element.
50 | HeaderReselect = "HX-Reselect"
51 | // Response header that allows you to trigger client-side events after the settle step.
52 | HeaderTriggerAfterSettle = "HX-Trigger-After-Settle"
53 | // Response header that allows you to trigger client-side events after the swap step.
54 | HeaderTriggerAfterSwap = "HX-Trigger-After-Swap"
55 | )
56 |
57 | // 286 Stop Polling
58 | //
59 | // HTTP status code that tells HTMX to stop polling from a server response.
60 | //
61 | // For more info, see https://htmx.org/docs/#load_polling
62 | const StatusStopPolling int = 286
63 |
--------------------------------------------------------------------------------
/request.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "net/http"
5 | )
6 |
7 | // IsHTMX returns true if the given request
8 | // was made by HTMX.
9 | //
10 | // This can be used to add special logic for HTMX requests.
11 | //
12 | // Checks if header 'HX-Request' is 'true'.
13 | func IsHTMX(r *http.Request) bool {
14 | return r.Header.Get(HeaderRequest) == "true"
15 | }
16 |
17 | // IsBoosted returns true if the given request
18 | // was made via an element using 'hx-boost'.
19 | //
20 | // This can be used to add special logic for boosted requests.
21 | //
22 | // Checks if header 'HX-Boosted' is 'true'.
23 | //
24 | // For more info, see https://htmx.org/attributes/hx-boost/
25 | func IsBoosted(r *http.Request) bool {
26 | return r.Header.Get(HeaderBoosted) == "true"
27 | }
28 |
29 | // IsHistoryRestoreRequest returns true if the given request
30 | // is for history restoration after a miss in the local history cache.
31 | //
32 | // Checks if header 'HX-History-Restore-Request' is 'true'.
33 | func IsHistoryRestoreRequest(r *http.Request) bool {
34 | return r.Header.Get(HeaderHistoryRestoreRequest) == "true"
35 | }
36 |
37 | // GetCurrentURL returns the current URL that HTMX made this request from.
38 | //
39 | // Returns false if header 'HX-Current-URL' does not exist.
40 | func GetCurrentURL(r *http.Request) (string, bool) {
41 | if _, ok := r.Header[http.CanonicalHeaderKey(HeaderCurrentURL)]; !ok {
42 | return "", false
43 | }
44 | return r.Header.Get(HeaderCurrentURL), true
45 | }
46 |
47 | // GetPrompt returns the user response to an hx-prompt from a given request.
48 | //
49 | // Returns false if header 'HX-Prompt' does not exist.
50 | //
51 | // For more info, see https://htmx.org/attributes/hx-prompt/
52 | func GetPrompt(r *http.Request) (string, bool) {
53 | if _, ok := r.Header[http.CanonicalHeaderKey(HeaderPrompt)]; !ok {
54 | return "", false
55 | }
56 | return r.Header.Get(HeaderPrompt), true
57 | }
58 |
59 | // GetTarget returns the ID of the target element if it exists from a given request.
60 | //
61 | // Returns false if header 'HX-Target' does not exist.
62 | //
63 | // For more info, see https://htmx.org/attributes/hx-target/
64 | func GetTarget(r *http.Request) (string, bool) {
65 | if _, ok := r.Header[http.CanonicalHeaderKey(HeaderTarget)]; !ok {
66 | return "", false
67 | }
68 | return r.Header.Get(HeaderTarget), true
69 | }
70 |
71 | // GetTriggerName returns the 'name' of the triggered element if it exists from a given request.
72 | //
73 | // Returns false if header 'HX-Trigger-Name' does not exist.
74 | //
75 | // For more info, see https://htmx.org/attributes/hx-trigger/
76 | func GetTriggerName(r *http.Request) (string, bool) {
77 | if _, ok := r.Header[http.CanonicalHeaderKey(HeaderTriggerName)]; !ok {
78 | return "", false
79 | }
80 | return r.Header.Get(HeaderTriggerName), true
81 | }
82 |
83 | // GetTrigger returns the ID of the triggered element if it exists from a given request.
84 | //
85 | // Returns false if header 'HX-Trigger' does not exist.
86 | //
87 | // For more info, see https://htmx.org/attributes/hx-trigger/
88 | func GetTrigger(r *http.Request) (string, bool) {
89 | if _, ok := r.Header[http.CanonicalHeaderKey(HeaderTrigger)]; !ok {
90 | return "", false
91 | }
92 | return r.Header.Get(HeaderTrigger), true
93 | }
94 |
--------------------------------------------------------------------------------
/respheaders.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "encoding/json"
5 | "strings"
6 | )
7 |
8 | const (
9 | trueString = "true"
10 | falseString = "false"
11 | )
12 |
13 | // StatusCode sets the HTTP response header of this response.
14 | //
15 | // If StatusCode is not called, the default status code will be 200 OK.
16 | func (r Response) StatusCode(statusCode int) Response {
17 | r.setStatusCode(statusCode)
18 | return r
19 | }
20 |
21 | // Internal method for StatusCode
22 | func (r *Response) setStatusCode(statusCode int) {
23 | r.statusCode = statusCode
24 | }
25 |
26 | // Location allows you to do a client-side redirect that does not do a full page reload.
27 | //
28 | // If you want to redirect to a specific target on the page rather than the default of document.body,
29 | // you can use [htmx.Response.LocationWithContext].
30 | //
31 | // Sets the 'HX-Location' header.
32 | //
33 | // For more info, see https://htmx.org/headers/hx-location/
34 | func (r Response) Location(path string) Response {
35 | r.headers[HeaderLocation] = path
36 | return r
37 | }
38 |
39 | // A context object that is used by [htmx.Response.LocationWithContext]
40 | // to finely determine the behavior of client-side redirection.
41 | //
42 | // In the browser, these are the parameters that will be used by 'htmx.ajax()'.
43 | //
44 | // For more info, see https://htmx.org/headers/hx-location/
45 | type LocationContext struct {
46 | // The source element of the request.
47 | Source string
48 | // An event that “triggered” the request.
49 | Event string
50 | // A JavaScript callback that will handle the response HTML.
51 | Handler string
52 | // The target to swap the response into.
53 | Target string
54 | // How the response will be swapped in relative to the target.
55 | Swap SwapStrategy
56 | // Values to submit with the request.
57 | Values map[string]string
58 | // Headers to submit with the request
59 | Headers map[string]string
60 | // Allows you to select the content you want swapped from a response.
61 | Select string
62 | }
63 |
64 | // Internal version of locationContext that just contains the "path" field
65 | // and JSON tags to serialize it to JSON.
66 | type locationContext struct {
67 | // Path to redirect to.
68 | Path string `json:"path"`
69 | Source string `json:"source,omitempty"`
70 | Event string `json:"event,omitempty"`
71 | Handler string `json:"handler,omitempty"`
72 | Target string `json:"target,omitempty"`
73 | Swap string `json:"swap,omitempty"`
74 | Values map[string]string `json:"values,omitempty"`
75 | Headers map[string]string `json:"headers,omitempty"`
76 | Select string `json:"select,omitempty"`
77 | }
78 |
79 | // LocationWithContext allows you to do a client-side redirect that does not do a full page reload,
80 | // redirecting to a specific target on the page with the given context.
81 | //
82 | // For simple redirects, you can just use [htmx.Response.Location].
83 | //
84 | // Sets the 'HX-Location' header.
85 | //
86 | // For more info, see https://htmx.org/headers/hx-location/
87 | func (r Response) LocationWithContext(path string, ctx LocationContext) Response {
88 | // Replace the error at the start because the last errors shouldn't really matter
89 | r.locationWithContextErr = make([]error, 0)
90 |
91 | c := locationContext{
92 | Path: path,
93 | Source: ctx.Source,
94 | Event: ctx.Event,
95 | Handler: ctx.Handler,
96 | Target: ctx.Target,
97 | Swap: ctx.Swap.swapString(),
98 | Values: ctx.Values,
99 | Headers: ctx.Headers,
100 | Select: ctx.Select,
101 | }
102 |
103 | bytes, err := json.Marshal(c)
104 | if err != nil {
105 | r.locationWithContextErr = append(r.locationWithContextErr, err)
106 | return r
107 | }
108 |
109 | r.headers[HeaderLocation] = string(bytes)
110 |
111 | return r
112 | }
113 |
114 | // PushURL pushes a new URL into the browser location history.
115 | //
116 | // Sets the same header as [htmx.Response.PreventPushURL], overwriting previous set headers.
117 | //
118 | // Sets the 'HX-Push-Url' header.
119 | //
120 | // For more info, see https://htmx.org/headers/hx-push-url/
121 | func (r Response) PushURL(url string) Response {
122 | r.headers[HeaderPushURL] = url
123 | return r
124 | }
125 |
126 | // PreventPushURL prevents the browser’s history from being updated.
127 | //
128 | // Sets the same header as [htmx.Response.PushURL], overwriting previous set headers.
129 | //
130 | // Sets the 'HX-Push-Url' header.
131 | //
132 | // For more info, see https://htmx.org/headers/hx-push-url/
133 | func (r Response) PreventPushURL() Response {
134 | r.headers[HeaderPushURL] = falseString
135 | return r
136 | }
137 |
138 | // Redirect does a client-side redirect to a new location.
139 | //
140 | // Sets the 'HX-Redirect' header.
141 | func (r Response) Redirect(path string) Response {
142 | r.headers[HeaderRedirect] = path
143 | return r
144 | }
145 |
146 | // If set to true, Refresh makes the client-side do a full refresh of the page.
147 | //
148 | // Sets the 'HX-Refresh' header.
149 | func (r Response) Refresh(shouldRefresh bool) Response {
150 | if shouldRefresh {
151 | r.headers[HeaderRefresh] = trueString
152 | } else {
153 | r.headers[HeaderRefresh] = falseString
154 | }
155 | return r
156 | }
157 |
158 | // ReplaceURL replaces the current URL in the browser location history.
159 | //
160 | // Sets the same header as [htmx.Response.PreventReplaceURL], overwriting previous set headers.
161 | //
162 | // Sets the 'HX-Replace-Url' header.
163 | //
164 | // For more info, see https://htmx.org/headers/hx-replace-url/
165 | func (r Response) ReplaceURL(url string) Response {
166 | r.headers[HeaderReplaceUrl] = url
167 | return r
168 | }
169 |
170 | // PreventReplaceURL prevents the browser’s current URL from being updated.
171 | //
172 | // Sets the same header as [htmx.Response.ReplaceURL], overwriting previous set headers.
173 | //
174 | // Sets the 'HX-Replace-Url' header to 'false'.
175 | //
176 | // For more info, see https://htmx.org/headers/hx-replace-url/
177 | func (r Response) PreventReplaceURL() Response {
178 | r.headers[HeaderReplaceUrl] = falseString
179 | return r
180 | }
181 |
182 | // Reswap allows you to specify how the response will be swapped.
183 | //
184 | // Sets the 'HX-Reswap' header.
185 | //
186 | // For more info, see https://htmx.org/attributes/hx-swap/
187 | func (r Response) Reswap(s SwapStrategy) Response {
188 | r.headers[HeaderReswap] = s.swapString()
189 | return r
190 | }
191 |
192 | // Retarget accepts a CSS selector that updates the target of the content update to a different element on the page. Overrides an existing 'hx-select' on the triggering element.
193 | //
194 | // Sets the 'HX-Retarget' header.
195 | //
196 | // For more info, see https://htmx.org/attributes/hx-target/
197 | func (r Response) Retarget(cssSelector string) Response {
198 | r.headers[HeaderRetarget] = cssSelector
199 | return r
200 | }
201 |
202 | // Reselect accepts a CSS selector that allows you to choose which part of the response is used to be swapped in.
203 | // Overrides an existing hx-select on the triggering element.
204 | //
205 | // Sets the 'HX-Reselect' header.
206 | //
207 | // For more info, see https://htmx.org/attributes/hx-select/
208 | func (r Response) Reselect(cssSelector string) Response {
209 | r.headers[HeaderReselect] = cssSelector
210 | return r
211 | }
212 |
213 | type (
214 | // EventTrigger gives an HTMX response directives to
215 | // triggers events on the client side.
216 | EventTrigger interface {
217 | htmxTrigger()
218 | }
219 |
220 | // Unexported with a public constructor function for type safety reasons
221 | triggerPlain string
222 | // Unexported with a public constructor function for type safety reasons
223 | triggerDetail struct {
224 | eventName string
225 | value string
226 | }
227 | // Unexported with a public constructor function for type safety reasons
228 | triggerObject struct {
229 | eventName string
230 | object any
231 | }
232 | )
233 |
234 | // trigger satisfies htmx.EventTrigger
235 | func (t triggerPlain) htmxTrigger() {}
236 |
237 | // triggerDetail satisfies htmx.EventTrigger
238 | func (t triggerDetail) htmxTrigger() {}
239 |
240 | // triggerObject satisfies htmx.EventTrigger
241 | func (t triggerObject) htmxTrigger() {}
242 |
243 | // Trigger returns an event trigger with no additional details.
244 | //
245 | // Example:
246 | //
247 | // htmx.Trigger("myEvent")
248 | //
249 | // Output header:
250 | //
251 | // HX-Trigger: myEvent
252 | //
253 | // For more info, see https://htmx.org/headers/hx-trigger/
254 | func Trigger(eventName string) triggerPlain {
255 | return triggerPlain(eventName)
256 | }
257 |
258 | // TriggerDetail returns an event trigger with one detail string.
259 | // Will be encoded as JSON.
260 | //
261 | // Example:
262 | //
263 | // htmx.TriggerDetail("showMessage", "Here Is A Message")
264 | //
265 | // Output header:
266 | //
267 | // HX-Trigger: {"showMessage":"Here Is A Message"}
268 | //
269 | // For more info, see https://htmx.org/headers/hx-trigger/
270 | func TriggerDetail(eventName string, detailValue string) triggerDetail {
271 | return triggerDetail{
272 | eventName: eventName,
273 | value: detailValue,
274 | }
275 | }
276 |
277 | // TriggerObject returns an event trigger with a given detail object that **must** be serializable to JSON.
278 | //
279 | // Structs with JSON tags can work, and so does `map[string]string` values which are safe to serialize.
280 | //
281 | // Example:
282 | //
283 | // htmx.TriggerObject("showMessage", map[string]string{
284 | // "level": "info",
285 | // "message": "Here Is A Message",
286 | // })
287 | //
288 | // Output header:
289 | //
290 | // HX-Trigger: {"showMessage":{"level" : "info", "message" : "Here Is A Message"}}
291 | //
292 | // For more info, see https://htmx.org/headers/hx-trigger/
293 | func TriggerObject(eventName string, detailObject any) triggerObject {
294 | return triggerObject{
295 | eventName: eventName,
296 | object: detailObject,
297 | }
298 | }
299 |
300 | // triggersToString converts a slice of triggers into a header value
301 | // for headers like 'HX-Trigger'.
302 | func triggersToString(triggers []EventTrigger) (string, error) {
303 | simpleEvents := make([]string, 0)
304 | detailEvents := make(map[string]any)
305 |
306 | for _, t := range triggers {
307 | switch v := t.(type) {
308 | case triggerPlain:
309 | simpleEvents = append(simpleEvents, string(v))
310 | case triggerObject:
311 | detailEvents[v.eventName] = v.object
312 | case triggerDetail:
313 | detailEvents[v.eventName] = v.value
314 | }
315 | }
316 |
317 | if len(detailEvents) == 0 {
318 | return strings.Join(simpleEvents, ", "), nil
319 | } else {
320 | for _, evt := range simpleEvents {
321 | detailEvents[evt] = ""
322 | }
323 |
324 | bytes, err := json.Marshal(detailEvents)
325 | if err != nil {
326 | return "", err
327 | }
328 |
329 | return string(bytes), nil
330 | }
331 | }
332 |
333 | // AddTrigger adds trigger(s) for events that trigger as soon as the response is received.
334 | //
335 | // This can be called multiple times so you can add as many triggers as you need.
336 | //
337 | // Sets the 'HX-Trigger' header.
338 | //
339 | // For more info, see https://htmx.org/headers/hx-trigger/
340 | func (r Response) AddTrigger(trigger ...EventTrigger) Response {
341 | r.initTriggers()
342 | r.triggers = append(r.triggers, trigger...)
343 | return r
344 | }
345 |
346 | // AddTriggerAfterSettle adds trigger(s) for events that trigger after the settling step.
347 | //
348 | // This can be called multiple times so you can add as many triggers as you need.
349 | //
350 | // Sets the 'HX-Trigger-After-Settle' header.
351 | //
352 | // For more info, see https://htmx.org/headers/hx-trigger/
353 | func (r Response) AddTriggerAfterSettle(trigger ...EventTrigger) Response {
354 | r.initTriggersAfterSettle()
355 | r.triggersAfterSettle = append(r.triggersAfterSettle, trigger...)
356 | return r
357 | }
358 |
359 | // AddTriggerAfterSwap adds trigger(s) for events that trigger after the swap step.
360 | //
361 | // This can be called multiple times so you can add as many triggers as you need.
362 | //
363 | // Sets the 'HX-Trigger-After-Swap' header.
364 | //
365 | // For more info, see https://htmx.org/headers/hx-trigger/
366 | func (r Response) AddTriggerAfterSwap(trigger ...EventTrigger) Response {
367 | r.initTriggersAfterSwap()
368 | r.triggersAfterSwap = append(r.triggersAfterSwap, trigger...)
369 | return r
370 | }
371 |
372 | // Lazily init the triggers slice because not all responses
373 | // use triggers
374 | func (r *Response) initTriggers() {
375 | if r.triggers == nil {
376 | r.triggers = make([]EventTrigger, 0)
377 | }
378 | }
379 |
380 | // Lazily init the triggersAfterSettle slice because not all responses
381 | // use triggers
382 | func (r *Response) initTriggersAfterSettle() {
383 | if r.triggersAfterSettle == nil {
384 | r.triggersAfterSettle = make([]EventTrigger, 0)
385 | }
386 | }
387 |
388 | // Lazily init the triggersAfterSwap slice because not all responses
389 | // use triggers
390 | func (r *Response) initTriggersAfterSwap() {
391 | if r.triggersAfterSwap == nil {
392 | r.triggersAfterSwap = make([]EventTrigger, 0)
393 | }
394 | }
395 |
--------------------------------------------------------------------------------
/response.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "html/template"
8 | "net/http"
9 | )
10 |
11 | // Response contains HTMX headers to write to a response.
12 | type Response struct {
13 | // The HTMX headers that will be written to a response.
14 | headers map[string]string
15 |
16 | // The HTTP status code to use
17 | statusCode int
18 |
19 | // Triggers for 'HX-Trigger'
20 | triggers []EventTrigger
21 |
22 | // Triggers for 'HX-Trigger-After-Settle'
23 | triggersAfterSettle []EventTrigger
24 |
25 | // Triggers for 'HX-Trigger-After-Swap'
26 | triggersAfterSwap []EventTrigger
27 |
28 | // JSON marshalling might fail, so we need to keep track of this error
29 | // to return when `Write` is called
30 | locationWithContextErr []error
31 | }
32 |
33 | // NewResponse returns a new HTMX response header writer.
34 | //
35 | // Any subsequent method calls that write to the same header
36 | // will overwrite the last set header value.
37 | func NewResponse() Response {
38 | return Response{
39 | headers: make(map[string]string),
40 | }
41 | }
42 |
43 | // Clone returns a clone of this HTMX response writer, preventing any mutation
44 | // on the original response.
45 | func (r Response) Clone() Response {
46 | n := NewResponse()
47 |
48 | for k, v := range r.headers {
49 | n.headers[k] = v
50 | }
51 |
52 | return n
53 | }
54 |
55 | // Write applies the defined HTMX headers to a given response writer.
56 | func (r Response) Write(w http.ResponseWriter) error {
57 | if len(r.locationWithContextErr) > 0 {
58 | return errors.Join(r.locationWithContextErr...)
59 | }
60 |
61 | headers, err := r.Headers()
62 | if err != nil {
63 | return err
64 | }
65 |
66 | headerWriter := w.Header()
67 | for k, v := range headers {
68 | headerWriter.Set(k, v)
69 | }
70 |
71 | // Status code needs to be written after the other headers
72 | // so the other headers can be written
73 | if r.statusCode != 0 {
74 | w.WriteHeader(r.statusCode)
75 | }
76 |
77 | return nil
78 | }
79 |
80 | // RenderHTML renders an HTML document fragment along with the defined HTMX headers.
81 | func (r Response) RenderHTML(w http.ResponseWriter, html template.HTML) (int, error) {
82 | err := r.Write(w)
83 | if err != nil {
84 | return 0, err
85 | }
86 |
87 | return w.Write([]byte(html))
88 | }
89 |
90 | // RenderTempl renders a Templ component along with the defined HTMX headers.
91 | func (r Response) RenderTempl(ctx context.Context, w http.ResponseWriter, c templComponent) error {
92 | err := r.Write(w)
93 | if err != nil {
94 | return err
95 | }
96 |
97 | err = c.Render(ctx, w)
98 | if err != nil {
99 | return err
100 | }
101 |
102 | return nil
103 | }
104 |
105 | // MustWrite applies the defined HTMX headers to a given response writer, otherwise it panics.
106 | //
107 | // Under the hood this uses [Response.Write].
108 | func (r Response) MustWrite(w http.ResponseWriter) {
109 | err := r.Write(w)
110 | if err != nil {
111 | panic(err)
112 | }
113 | }
114 |
115 | // MustRenderHTML renders an HTML document fragment along with the defined HTMX headers, otherwise it panics.
116 | //
117 | // Under the hood this uses [Response.RenderHTML].
118 | func (r Response) MustRenderHTML(w http.ResponseWriter, html template.HTML) {
119 | _, err := r.RenderHTML(w, html)
120 | if err != nil {
121 | panic(err)
122 | }
123 | }
124 |
125 | // MustRenderTempl renders a Templ component along with the defined HTMX headers, otherwise it panics.
126 | //
127 | // Under the hood this uses [Response.RenderTempl].
128 | func (r Response) MustRenderTempl(ctx context.Context, w http.ResponseWriter, c templComponent) {
129 | err := r.RenderTempl(ctx, w, c)
130 | if err != nil {
131 | panic(err)
132 | }
133 | }
134 |
135 | // Headers returns a copied map of the headers. Any modifications to the
136 | // returned headers will not affect the headers in this struct.
137 | func (r Response) Headers() (map[string]string, error) {
138 | m := make(map[string]string)
139 |
140 | for k, v := range r.headers {
141 | m[k] = v
142 | }
143 |
144 | if r.triggers != nil {
145 | triggers, err := triggersToString(r.triggers)
146 | if err != nil {
147 | return nil, fmt.Errorf("marshalling triggers failed: %w", err)
148 | }
149 | m[HeaderTrigger] = triggers
150 | }
151 |
152 | if r.triggersAfterSettle != nil {
153 | triggers, err := triggersToString(r.triggersAfterSettle)
154 | if err != nil {
155 | return nil, fmt.Errorf("marshalling triggers after settle failed: %w", err)
156 | }
157 | m[HeaderTriggerAfterSettle] = triggers
158 | }
159 |
160 | if r.triggersAfterSwap != nil {
161 | triggers, err := triggersToString(r.triggersAfterSwap)
162 | if err != nil {
163 | return nil, fmt.Errorf("marshalling triggers after swap failed: %w", err)
164 | }
165 | m[HeaderTriggerAfterSwap] = triggers
166 | }
167 |
168 | return m, nil
169 | }
170 |
--------------------------------------------------------------------------------
/response_test.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "html/template"
5 | "net/http"
6 | "testing"
7 | )
8 |
9 | func TestWrite(t *testing.T) {
10 | w := newMockResponseWriter()
11 |
12 | err := NewResponse().
13 | StatusCode(StatusStopPolling).
14 | Location("/profiles").
15 | Redirect("/pull").
16 | PushURL("/push").
17 | Refresh(true).
18 | ReplaceURL("/water").
19 | Retarget("#world").
20 | Reselect("#hello").
21 | AddTrigger(Trigger("myEvent")).
22 | Reswap(SwapInnerHTML.ShowOn("#swappy", Top)).
23 | Write(w)
24 | if err != nil {
25 | t.Errorf("an error occurred writing a response: %v", err)
26 | }
27 |
28 | if w.statusCode != StatusStopPolling {
29 | t.Errorf("wrong error code. want=%v, got=%v", StatusStopPolling, w.statusCode)
30 | }
31 |
32 | expectedHeaders := map[string]string{
33 | HeaderTrigger: "myEvent",
34 | HeaderLocation: "/profiles",
35 | HeaderRedirect: "/pull",
36 | HeaderPushURL: "/push",
37 | HeaderRefresh: "true",
38 | HeaderReplaceUrl: "/water",
39 | HeaderRetarget: "#world",
40 | HeaderReselect: "#hello",
41 | HeaderReswap: "innerHTML show:#swappy:top",
42 | }
43 |
44 | for k, v := range expectedHeaders {
45 | got := w.header.Get(k)
46 | if got != v {
47 | t.Errorf("wrong value for header %q. got=%q, want=%q", k, got, v)
48 | }
49 | }
50 | }
51 |
52 | func TestRenderHTML(t *testing.T) {
53 | text := `hello world!`
54 |
55 | w := newMockResponseWriter()
56 |
57 | _, err := NewResponse().Location("/conversation/message").RenderHTML(w, template.HTML(text))
58 | if err != nil {
59 | t.Errorf("an error occurred writing HTML: %v", err)
60 | }
61 |
62 | if got, want := w.Header().Get(HeaderLocation), "/conversation/message"; got != want {
63 | t.Errorf("wrong value for header %q. got=%q, want=%q", HeaderLocation, got, want)
64 | }
65 |
66 | if string(w.body) != text {
67 | t.Errorf("wrong response body. got=%q, want=%q", string(w.body), text)
68 | }
69 | }
70 |
71 | func TestMustRenderHTML(t *testing.T) {
72 | text := `hello world!`
73 |
74 | w := newMockResponseWriter()
75 |
76 | NewResponse().MustRenderHTML(w, template.HTML(text))
77 | }
78 |
79 | type mockResponseWriter struct {
80 | body []byte
81 | statusCode int
82 | header http.Header
83 | }
84 |
85 | func newMockResponseWriter() *mockResponseWriter {
86 | return &mockResponseWriter{
87 | header: http.Header{},
88 | }
89 | }
90 |
91 | func (mrw *mockResponseWriter) Header() http.Header {
92 | return mrw.header
93 | }
94 |
95 | func (mrw *mockResponseWriter) Write(b []byte) (int, error) {
96 | mrw.body = append(mrw.body, b...)
97 | return 0, nil
98 | }
99 |
100 | func (mrw *mockResponseWriter) WriteHeader(statusCode int) {
101 | mrw.statusCode = statusCode
102 | }
103 |
--------------------------------------------------------------------------------
/swap.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "strings"
5 | "time"
6 | )
7 |
8 | // SwapStrategy is an 'hx-swap' value that determines the swapping strategy
9 | // of [htmx.Response.Reswap] and [LocationContext].
10 | //
11 | // SwapStrategy methods add modifiers to change the behavior of the swap.
12 | type SwapStrategy string
13 |
14 | const (
15 | // Replace the inner html of the target element.
16 | //
17 | // Valid value for [Response.Reswap].
18 | SwapInnerHTML SwapStrategy = "innerHTML"
19 |
20 | // Replace the entire target element with the response.
21 | //
22 | // Valid value for [Response.Reswap].
23 | SwapOuterHTML SwapStrategy = "outerHTML"
24 |
25 | // Insert the response before the target element.
26 | //
27 | // Valid value for [Response.Reswap].
28 | SwapBeforeBegin SwapStrategy = "beforebegin"
29 |
30 | // Insert the response before the first child of the target element.
31 | //
32 | // Valid value for [Response.Reswap].
33 | SwapAfterBegin SwapStrategy = "afterbegin"
34 |
35 | // Insert the response after the last child of the target element.
36 | //
37 | // Valid value for [Response.Reswap].
38 | SwapBeforeEnd SwapStrategy = "beforeend"
39 |
40 | // Insert the response after the target element.
41 | //
42 | // Valid value for [Response.Reswap].
43 | SwapAfterEnd SwapStrategy = "afterend"
44 |
45 | // Deletes the target element regardless of the response.
46 | //
47 | // Valid value for [Response.Reswap].
48 | SwapDelete SwapStrategy = "delete"
49 |
50 | // Does not append content from response (out of band items will still be processed).
51 | //
52 | // Valid value for [Response.Reswap].
53 | SwapNone SwapStrategy = "none"
54 |
55 | // Uses the default swap style (default in HTMX is [SwapInnerHTML]).
56 | // This value is useful for adding modifiers to the [SwapStrategy]
57 | // through methods
58 | // without changing the default swap style.
59 | //
60 | //
61 | // Valid value for [Response.Reswap].
62 | SwapDefault SwapStrategy = ""
63 | )
64 |
65 | func (s SwapStrategy) swapString() string {
66 | return string(s)
67 | }
68 |
69 | // join joins any amount of strings together with a space in between.
70 | func join(elems ...string) string {
71 | // TrimSpace is needed because strings.Join inserts
72 | // the separator string (spaces) at the start of the string
73 | return strings.TrimSpace(strings.Join(elems, " "))
74 | }
75 |
76 | func (s SwapStrategy) cutPrefix(prefix string) string {
77 | words := strings.Split(s.swapString(), " ")
78 | filteredWords := make([]string, len(words))
79 |
80 | for _, word := range words {
81 | if strings.HasPrefix(word, prefix+":") {
82 | continue
83 | }
84 | filteredWords = append(filteredWords, word)
85 | }
86 |
87 | return join(filteredWords...)
88 | }
89 |
90 | func (s SwapStrategy) boolModifier(prefix string, b bool) SwapStrategy {
91 | v := s.cutPrefix(prefix)
92 |
93 | v = join(v, prefix+":")
94 | if b {
95 | v = v + "true"
96 | } else {
97 | v = v + "false"
98 | }
99 |
100 | return SwapStrategy(v)
101 | }
102 |
103 | func (s SwapStrategy) timeModifier(prefix string, duration time.Duration) SwapStrategy {
104 | v := s.cutPrefix(prefix)
105 | v = join(v, prefix+":"+duration.String())
106 | return SwapStrategy(v)
107 | }
108 |
109 | // Transition makes the swap use the new View Transitions API.
110 | //
111 | // Adds the 'transition:' modifier.
112 | //
113 | // For more info, see https://htmx.org/attributes/hx-swap/
114 | func (s SwapStrategy) Transition(shouldTransition bool) SwapStrategy {
115 | return s.boolModifier("transition", shouldTransition)
116 | }
117 |
118 | // IgnoreTitle prevents HTMX from updating the title of the page
119 | // if there is a '' tag in the response content.
120 | //
121 | // By default, HTMX updates the title.
122 | //
123 | // Adds the 'ignoreTitle:' modifier.
124 | //
125 | // For more info, see https://htmx.org/attributes/hx-swap/
126 | func (s SwapStrategy) IgnoreTitle(shouldIgnore bool) SwapStrategy {
127 | return s.boolModifier("ignoreTitle", shouldIgnore)
128 | }
129 |
130 | // FocusScroll enables focus scroll to automatically scroll
131 | // to the focused element after a request.
132 | //
133 | // Adds the 'focusScroll:' modifier.
134 | //
135 | // For more info, see https://htmx.org/attributes/hx-swap/
136 | func (s SwapStrategy) FocusScroll(shouldFocus bool) SwapStrategy {
137 | return s.boolModifier("focusScroll", shouldFocus)
138 | }
139 |
140 | // After modifies the amount of time that HTMX will wait
141 | // after receiving a response to swap the content.
142 | //
143 | // Adds the 'swap:' modifier.
144 | //
145 | // For more info, see https://htmx.org/attributes/hx-swap/
146 | func (s SwapStrategy) After(duration time.Duration) SwapStrategy {
147 | return s.timeModifier("swap", duration)
148 | }
149 |
150 | // SettleAfter modifies the amount of time that HTMX will wait
151 | // after the swap before executing the settle logic.
152 | //
153 | // Adds the 'settle:' modifier.
154 | //
155 | // For more info, see https://htmx.org/attributes/hx-swap/
156 | func (s SwapStrategy) SettleAfter(duration time.Duration) SwapStrategy {
157 | return s.timeModifier("settle", duration)
158 | }
159 |
160 | type (
161 | // Direction is a value for the [htmx.SwapStrategy] 'scroll' and 'show' modifier methods.
162 | //
163 | // Possible values are [htmx.Top] and [htmx.Bottom].
164 | Direction interface {
165 | dirString() string
166 | }
167 |
168 | direction string
169 | )
170 |
171 | func (d direction) dirString() string {
172 | return string(d)
173 | }
174 |
175 | // Direction values for the [htmx.SwapStrategy] 'scroll' and 'show' modifier methods.
176 | const (
177 | // Direction value for the 'scroll' and 'show' swap modifier methods.
178 | Top direction = "top"
179 | // Direction value for the 'scroll' and 'show' swap modifier methods.
180 | Bottom direction = "bottom"
181 | )
182 |
183 | // Scroll changes the scrolling behavior based on the given direction.
184 | //
185 | // Scroll([htmx.Top]) scrolls to the top of the swapped-in element.
186 | //
187 | // Scroll([htmx.Bottom]) scrolls to the bottom of the swapped-in element.
188 | //
189 | // Adds the 'scroll:' modifier.
190 | //
191 | // For more info, see https://htmx.org/attributes/hx-swap/
192 | func (s SwapStrategy) Scroll(direction Direction) SwapStrategy {
193 | v := s.cutPrefix("scroll")
194 | mod := "scroll:" + direction.dirString()
195 | return SwapStrategy(join(v, mod))
196 | }
197 |
198 | // ScrollOn changes the scrolling behavior based on the given direction and CSS selector.
199 | //
200 | // ScrollOn(cssSelector, [htmx.Top]) scrolls to the top of the element found by the selector.
201 | //
202 | // ScrollOn(cssSelector, [htmx.Bottom]) scrolls to the bottom of the element found by the selector.
203 | //
204 | // Adds the 'scroll::' modifier.
205 | //
206 | // For more info, see https://htmx.org/attributes/hx-swap/
207 | func (s SwapStrategy) ScrollOn(cssSelector string, direction Direction) SwapStrategy {
208 | v := s.cutPrefix("scroll")
209 | mod := "scroll:" + cssSelector + ":" + direction.dirString()
210 | return SwapStrategy(join(v, mod))
211 | }
212 |
213 | // ScrollWindow changes the scrolling behavior based on the given direction.
214 | //
215 | // ScrollWindow([htmx.Top]) scrolls to the very top of the window.
216 | //
217 | // ScrollWindow([htmx.Bottom]) scrolls to the very bottom of the window.
218 | //
219 | // Adds the 'scroll:window:' modifier.
220 | //
221 | // For more info, see https://htmx.org/attributes/hx-swap/
222 | func (s SwapStrategy) ScrollWindow(direction Direction) SwapStrategy {
223 | v := s.cutPrefix("scroll")
224 | mod := "scroll:window:" + direction.dirString()
225 | return SwapStrategy(join(v, mod))
226 | }
227 |
228 | // Show changes the show behavior based on the given direction.
229 | //
230 | // Show([htmx.Top]) shows the top of the swapped-in element.
231 | //
232 | // Show([htmx.Bottom]) shows the bottom of the swapped-in element.
233 | //
234 | // Adds the 'show:' modifier.
235 | //
236 | // For more info, see https://htmx.org/attributes/hx-swap/
237 | func (s SwapStrategy) Show(direction Direction) SwapStrategy {
238 | v := s.cutPrefix("show")
239 | mod := "show:" + direction.dirString()
240 | return SwapStrategy(join(v, mod))
241 | }
242 |
243 | // ShowOn changes the show behavior based on the given direction and CSS selector.
244 | //
245 | // ShowOn(cssSelector, [htmx.Top]) shows the top of the element found by the selector.
246 | //
247 | // ShowOn(cssSelector, [htmx.Bottom]) shows the bottom of the element found by the selector.
248 | //
249 | // Adds the 'show::' modifier.
250 | //
251 | // For more info, see https://htmx.org/attributes/hx-swap/
252 | func (s SwapStrategy) ShowOn(cssSelector string, direction Direction) SwapStrategy {
253 | v := s.cutPrefix("show")
254 | mod := "show:" + cssSelector + ":" + direction.dirString()
255 | return SwapStrategy(join(v, mod))
256 | }
257 |
258 | // ShowWindow changes the show behavior based on the given direction.
259 | //
260 | // ScrollWindow([htmx.Top]) shows the very top of the window.
261 | //
262 | // ScrollWindow([htmx.Bottom]) shows the very bottom of the window.
263 | //
264 | // Adds the 'show:window:' modifier.
265 | //
266 | // For more info, see https://htmx.org/attributes/hx-swap/
267 | func (s SwapStrategy) ShowWindow(direction Direction) SwapStrategy {
268 | v := s.cutPrefix("show")
269 | mod := "show:window:" + direction.dirString()
270 | return SwapStrategy(join(v, mod))
271 | }
272 |
273 | // ShowNone disables 'show'.
274 | //
275 | // Adds the 'show:none' modifier.
276 | //
277 | // For more info, see https://htmx.org/attributes/hx-swap/
278 | func (s SwapStrategy) ShowNone() SwapStrategy {
279 | v := s.cutPrefix("show")
280 | mod := "show:none"
281 | return SwapStrategy(join(v, mod))
282 | }
283 |
--------------------------------------------------------------------------------
/swap_test.go:
--------------------------------------------------------------------------------
1 | package htmx
2 |
3 | import (
4 | "testing"
5 | "time"
6 | )
7 |
8 | func TestSwapStrategy_SwapString(t *testing.T) {
9 | testCases := []struct {
10 | name string
11 | swapStrategy SwapStrategy
12 | result string
13 | }{
14 | {
15 | name: "no modifier",
16 | swapStrategy: SwapInnerHTML,
17 | result: "innerHTML",
18 | },
19 | {
20 | name: "one modifier",
21 | swapStrategy: SwapInnerHTML.Transition(true),
22 | result: "innerHTML transition:true",
23 | },
24 | {
25 | name: "many modifiers",
26 | swapStrategy: SwapInnerHTML.Transition(true).
27 | IgnoreTitle(true).
28 | FocusScroll(true).
29 | After(5*time.Second).
30 | SettleAfter(5*time.Second).
31 | Scroll(Top).
32 | ScrollOn("#another-div", Top).
33 | ScrollWindow(Top).
34 | Show(Top).
35 | ShowOn("#another-div", Top).ShowWindow(Top).
36 | ShowNone(),
37 | result: "innerHTML transition:true ignoreTitle:true focusScroll:true swap:5s settle:5s scroll:window:top show:none",
38 | },
39 | }
40 |
41 | for _, tc := range testCases {
42 | if result := tc.swapStrategy.swapString(); result != tc.result {
43 | t.Errorf(`got: "%v", want: "%v"`, result, tc.result)
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------