├── .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 | [![GoDoc](https://pkg.go.dev/badge/github.com/angelofallars/htmx-go?status.svg)](https://pkg.go.dev/github.com/angelofallars/htmx-go?tab=doc) 4 | [![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/angelofallars/htmx-go/go.yml?cacheSeconds=30)](https://github.com/angelofallars/htmx-go/actions) 5 | [![License](https://img.shields.io/github/license/angelofallars/htmx-go)](./LICENSE) 6 | [![Stars](https://img.shields.io/github/stars/angelofallars/htmx-go)](https://github.com/angelofallars/htmx-go/stargazers) 7 | [![Discord](https://img.shields.io/discord/725789699527933952?label=htmx%20discord)](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:<true | false>' 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:<true | false>' 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:<duration>' 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:<duration>' 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:<direction ("top" | "bottom")>' 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:<cssSelector>:<direction ("top" | "bottom")>' 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:<direction ("top" | "bottom")>' 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:<direction ("top" | "bottom")>' 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:<cssSelector>:<direction ("top" | "bottom")>' 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:<direction ("top" | "bottom")>' 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 | --------------------------------------------------------------------------------