diff --git a/.github/workflows/phpcsfixer.yml b/.github/workflows/phpcsfixer.yml
index e21e695..a81b095 100644
--- a/.github/workflows/phpcsfixer.yml
+++ b/.github/workflows/phpcsfixer.yml
@@ -29,7 +29,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php-versions: ['8.1', '8.2', '8.4']
+ php-versions: ['8.2', '8.5']
steps:
- name: Checkout
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
index 79b546b..15ecd82 100644
--- a/.github/workflows/phpstan.yml
+++ b/.github/workflows/phpstan.yml
@@ -26,7 +26,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- php-versions: ['8.1', '8.4']
+ php-versions: ['8.2', '8.4']
steps:
- name: Checkout
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
index 559b01c..5c26f98 100644
--- a/.github/workflows/phpunit.yml
+++ b/.github/workflows/phpunit.yml
@@ -25,7 +25,7 @@ jobs:
if: "!contains(github.event.head_commit.message, '[ci skip]')"
strategy:
matrix:
- php-versions: ['8.1', '8.2', '8.3', '8.4', '8.5']
+ php-versions: ['8.2', '8.3', '8.4', '8.5']
steps:
- name: Free Disk Space (Ubuntu)
diff --git a/README.md b/README.md
index 501193e..3b962ce 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,11 @@
# CodeIgniter HTMX
-A set of methods for `IncomingRequest`, `Response` and `RedirectResponse` classes to help you work with [htmx](https://htmx.org) fluently in CodeIgniter 4 framework.
+A set of methods for `IncomingRequest`, `Response` and `RedirectResponse` classes to help you work with [htmx](https://four.htmx.org/) fluently in CodeIgniter 4 framework.
It also provides some additional help with **handling errors** and **Debug Toolbar** in development mode as well as support for **view fragments**.
+The `develop` branch targets the HTMX 4 request/response model and development event lifecycle.
+
[](https://github.com/michalsn/codeigniter-htmx/actions/workflows/phpunit.yml)
[](https://github.com/michalsn/codeigniter-htmx/actions/workflows/phpstan.yml)
[](https://github.com/michalsn/codeigniter-htmx/actions/workflows/deptrac.yml)
diff --git a/composer.json b/composer.json
index eec9c34..a350e9e 100644
--- a/composer.json
+++ b/composer.json
@@ -13,11 +13,11 @@
],
"homepage": "https://github.com/michalsn/codeigniter-htmx",
"require": {
- "php": "^8.0"
+ "php": "^8.2"
},
"require-dev": {
- "codeigniter4/devkit": "^1.0",
- "codeigniter4/framework": "^4.3"
+ "codeigniter4/devkit": "^1.3",
+ "codeigniter4/framework": "^4.7"
},
"minimum-stability": "dev",
"prefer-stable": true,
diff --git a/docs/configuration.md b/docs/configuration.md
index 7327837..5f4e33d 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -42,7 +42,6 @@ In the `production` environment these decorators are ignored by design. So this
Specifies whether the HTMX request URL should be stored in the session, for use with the `previous_url()` helper function.
For more information, see the [user guide](https://codeigniter.com/user_guide/helpers/url_helper.html#previous_url).
-Basically, if you use HTMX extensively, including for navigating your site, you will probably want to leave it as `true`,
-and in cases where storing the request is not desirable, even if it uses HTMX, you can use custom header, to indicate the
-AJAX call or [ajax-header](https://github.com/bigskysoftware/htmx-extensions/blob/main/src/ajax-header/README.md) extension,
-which will add the necessary headers automatically. URLs from AJAX requests are always excluded from session storage.
+Basically, if you use HTMX extensively, including for navigating your site, you will probably want to leave it as `true`.
+If storing the request is not desirable, mark it as a traditional AJAX request or use a custom header in your application flow.
+URLs from AJAX requests are always excluded from session storage.
diff --git a/docs/debug_toolbar.md b/docs/debug_toolbar.md
index 3574c46..326643c 100644
--- a/docs/debug_toolbar.md
+++ b/docs/debug_toolbar.md
@@ -1,6 +1,6 @@
# Debug Toolbar
-As long as you **don't use** the [head-support](https://htmx.org/extensions/head-support/) extension,
+As long as you **don't use** the [head-support](https://four.htmx.org/docs/extensions/head-support) extension,
the Debug Toolbar should work out of the box. It will be updated after every request, so please remember
it will only display the latest information. If you want to see what happened in earlier request,
use the `History` tab in the Toolbar.
diff --git a/docs/error_handling.md b/docs/error_handling.md
index 8e36a0b..4226f19 100644
--- a/docs/error_handling.md
+++ b/docs/error_handling.md
@@ -1,5 +1,25 @@
# Error handling
-By default, when an HTTP error response occurs, htmx is not displaying the error. This library changes it so that in the development mode, errors are displayed in a modal window.
+HTMX 4 handles error responses through its response handling rules, using `hx-status:*` attributes and `htmx.config.noSwap`.
+
+In development mode, when `errorModalDecorator` is enabled, this library overrides that default browser-side behavior for failed HTMX requests.
+
+Instead of allowing HTMX to continue with its normal response handling, the raw response is displayed in a modal window and the normal HTMX swap is skipped.
+
+This makes it easier to inspect exception pages, validation output, and malformed HTML returned during development, without changing the actual HTTP status code.
+
+HTML error pages are shown in a sandboxed preview iframe, with a source view available for inspecting the raw response. JSON and plain-text responses are shown directly as source.
+
+If you want to use HTMX's native error handling rules in development instead, disable `errorModalDecorator` in the config.
+
+When the decorator is disabled, you can configure HTMX directly, for example:
+
+```html
+
+```
+
+You can also use `hx-status:*` attributes to define per-status error targets in your application.
This feature can be disabled in the [Config](configuration.md) file.
diff --git a/docs/html_formatter.md b/docs/html_formatter.md
index a1dbf12..1a5b07b 100644
--- a/docs/html_formatter.md
+++ b/docs/html_formatter.md
@@ -10,9 +10,11 @@ We should edit the `app/Config/Format.php` file to include the necessary changes
php spark htmx:publish
-Since content negotiation will be triggered for any format other than `json` or `xml`, we have two options:
+With HTMX 4, requests already send `Accept: text/html`, so in the most common HTMX case no extra client-side configuration is needed.
-1. Set the custom headers for every request via HTML tag
+If you want to use the formatter outside HTMX requests, you still have two options:
+
+1. Set the custom headers for a request explicitly
```html
hx-headers='{"Accept":"text/html"}'
```
@@ -28,7 +30,7 @@ Since content negotiation will be triggered for any format other than `json` or
### Example
-This is an sample of using HTML formatter:
+This is a sample of using HTML formatter:
```php
+ htmx.config.implicitInheritance = true;
+ htmx.config.noSwap = [204, 304, '4xx', '5xx'];
+
+```
+
+This is only a transition aid. For a native HTMX 4 setup, prefer explicit `:inherited` attributes and keep the default `noSwap` behavior unless your application needs something else.
+
+## IncomingRequest changes
+
+Use the new methods when working with HTMX 4 requests:
+
+```php
+$this->request->getSource();
+$this->request->getRequestType();
+$this->request->isPartial();
+$this->request->isFull();
+```
+
+## Response behavior
+
+HTMX 4 still supports the response headers used by this package, including:
+
+- `HX-Location`
+- `HX-Push-Url`
+- `HX-Redirect`
+- `HX-Refresh`
+- `HX-Replace-Url`
+- `HX-Retarget`
+- `HX-Reswap`
+- `HX-Reselect`
+- `HX-Trigger`
+
+## Error responses
+
+If you want to avoid swapping `4xx` and `5xx` responses, configure:
+
+```html
+
+```
+
+You can also enable HTMX 2 compatibility mode in the browser:
+
+```html
+
+
+```
+
+## Attribute inheritance
+
+HTMX 4 uses explicit inheritance by default.
+
+If your markup relied on inherited attributes such as `hx-target`, `hx-boost`, or `hx-confirm`, update the attributes to use the `:inherited` modifier:
+
+```html
+
+
+
+```
+
+If you need the old behavior:
+
+```html
+
+```
+
+For larger migrations, the HTMX 2 compatibility extension can be a more convenient temporary bridge than enabling `implicitInheritance` globally.
+
+## Request markup changes
+
+HTMX 4 also changes a few client-side attributes that may affect applications using this package:
+
+- `hx-delete` no longer includes enclosing form values automatically. Use `hx-include="closest form"` when needed.
+- In HTMX 2, `hx-disable` prevented HTMX processing. In HTMX 4 that behavior moved to `hx-ignore`, while `hx-disabled-elt` was renamed to `hx-disable`.
+- `hx-request` was replaced by `hx-config`.
+- `hx-prompt` was removed. Use `hx-confirm` with JavaScript instead.
+- `hx-ext` was removed. Extensions now register through standard events, so load the extension script directly and use its attributes where needed.
+- `hx-history` and `hx-history-elt` were removed.
+- `data-hx-*` attributes are no longer recognized automatically unless you configure `htmx.config.prefix`.
+
+## History behavior
+
+HTMX 4 no longer stores page snapshots in browser storage.
+
+When the user navigates through history, HTMX issues a new full-page request instead. The `HX-History-Restore-Request` header is still available, so this package continues to expose `isHistoryRestoreRequest()`.
+
+If you want hard browser reloads on history navigation instead of HTMX restoration, use:
+
+```html
+
+```
+
+## Out-of-band swap order
+
+In HTMX 4, the main swap happens before `hx-swap-oob` content.
+
+If your UI depends on out-of-band fragments being processed first, review the markup and make each swap independent.
+
+## Timeouts
+
+HTMX 4 sets a 60-second request timeout by default:
+
+```html
+
+```
+
+If your application expects no timeout, set it back to `0`.
+
+## Extensions
+
+Extensions are now loaded by including their scripts directly.
+
+```html
+
+
+```
+
+You can optionally restrict which extensions may load with `htmx.config.extensions`.
+
+## Caching
+
+If your application returns different HTML for full and partial HTMX requests, configure caching carefully and consider sending:
+
+```http
+Vary: HX-Request-Type
+```
+
+If responses also depend on the source or target element, extend the `Vary` header accordingly.
+
+## References
+
+- [HTMX 4 migration guide](https://four.htmx.org/docs/get-started/migration)
+- [HTMX 4 reference](https://four.htmx.org/reference/)
diff --git a/docs/incoming_request.md b/docs/incoming_request.md
index 85a8ae6..5e03518 100644
--- a/docs/incoming_request.md
+++ b/docs/incoming_request.md
@@ -5,18 +5,17 @@ Available methods:
- [isHtmx()](#ishtmx)
- [isBoosted()](#isboosted)
- [isHistoryRestoreRequest()](#ishistoryrestorerequest)
+- [getRequestType()](#getrequesttype)
+- [isPartial()](#ispartial)
+- [isFull()](#isfull)
- [getCurrentUrl()](#getcurrenturl)
-- [getPrompt()](#getprompt)
+- [getSource()](#getsource)
- [getTarget()](#gettarget)
-- [getTrigger()](#gettrigger)
-- [getTriggerName()](#gettriggername)
-- [getTriggeringEvent()](#gettriggeringevent)
- [is()](#is)
### isHtmx()
-Checks if there is a `HX-Request` header in place.
-Indicates that the request was fired with htmx.
+Checks if the request carries the `HX-Request` header with the value `true`.
```php
$this->request->isHtmx();
@@ -25,7 +24,7 @@ $this->request->isHtmx();
### isBoosted()
Checks if there is a `HX-Boosted` header in place.
-Indicates that the request is via an element using [hx-boost](https://htmx.org/attributes/hx-boost)
+Indicates that the request is via an element using [hx-boost](https://four.htmx.org/reference/attributes/hx-boost)
```php
$this->request->isBoosted();
@@ -34,65 +33,75 @@ $this->request->isBoosted();
### isHistoryRestoreRequest()
Checks if there is a `HX-History-Restore-Request` header in place.
-True if the request is for history restoration after a miss in the local history cache.
+True if the request is for history restoration.
```php
$this->request->isHistoryRestoreRequest();
```
-### getCurrentUrl()
+### getRequestType()
-Checks the `HX-Current-URL` header and return current URL of the browser.
+Checks the `HX-Request-Type` header.
+Returns `partial` for targeted swaps and `full` for body-level requests, including `hx-boost` requests, or requests using `hx-select`.
```php
-$this->request->getCurrentUrl();
+$this->request->getRequestType();
```
-### getPrompt()
+### isPartial()
-Checks the `HX-Prompt` header - the user response to an [hx-prompt](https://htmx.org/attributes/hx-prompt/).
+Convenience method for checking if `HX-Request-Type` equals `partial`.
```php
-$this->request->getPrompt();
+$this->request->isPartial();
```
-### getTarget()
+### isFull()
-Checks the `HX-Target` header. Returns the `id` of the target element if it exists.
+Convenience method for checking if `HX-Request-Type` equals `full`.
```php
-$this->request->getTarget();
+$this->request->isFull();
```
-### getTrigger()
+### getCurrentUrl()
-Checks the `HX-Trigger` header. Returns the `id` of the triggered element if it exists.
+Checks the `HX-Current-URL` header and return current URL of the browser.
```php
-$this->request->getTrigger();
+$this->request->getCurrentUrl();
```
-### getTriggerName()
+### getSource()
-Checks the `HX-Trigger-Name` header. Returns the `name` of the triggered element if it exists.
+Checks the `HX-Source` header.
+In HTMX 4 this identifies the triggering element using the element identifier format, for example `button#save`.
```php
-$this->request->getTriggerName();
+$this->request->getSource();
```
-### getTriggeringEvent()
+### getTarget()
-Checks the `Triggering-Event` header. The value of the header is a JSON serialized version of the event that triggered the request.
-Check the [event-header](https://htmx.org/extensions/event-header/) plugin for more information.
+Checks the `HX-Target` header.
+In HTMX 4 it identifies the target element using the element identifier format, for example `div#results`.
+
+```php
+$this->request->getTarget();
+```
### is()
This new method is available in CodeIgniter since v4.3. It's a handful shortcut and alternative to another CodeIgniter method: `getMethod()`. But it also provides different types of checks - you can read more about it in the [user guide](https://codeigniter.com/user_guide/incoming/incomingrequest.html#is).
-Along with this library, we added two new parameters that can be used: `htmx` and `boosted` which are equivalent of using `isHtmx()` and `isBoosted()` methods.
+Along with this library, we added extra parameters that can be used: `htmx`, `boosted`, `partial`, and `full`.
```php
$this->request->is('htmx');
// or
$this->request->is('boosted');
+// or
+$this->request->is('partial');
+// or
+$this->request->is('full');
```
diff --git a/docs/index.md b/docs/index.md
index 1c348e2..708e817 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,6 +4,8 @@ This library is set of methods for `IncomingRequest`, `Response` and `RedirectRe
It also provides some additional help with **handling errors** and **Debug Toolbar** in development mode as well as support for **view fragments**.
+The current `develop` branch tracks the HTMX 4 request/response model and event lifecycle. For HTMX 2 projects, please use the `v2` branch.
+
### Requirements

@@ -15,12 +17,13 @@ It also provides some additional help with **handling errors** and **Debug Toolb
* [Configuration](configuration.md)
* [Error handling](error_handling.md)
* [View fragments](view_fragments.md)
-* [IncomingReqeuest](incoming_request.md)
+* [IncomingRequest](incoming_request.md)
* [Response](response.md)
-* [RediretResponse](redirect_response.md)
+* [RedirectResponse](redirect_response.md)
* [HTML Formatter](html_formatter.md)
* [Debug Toolbar](debug_toolbar.md)
* [Troubleshooting](troubleshooting.md)
+* [HTMX 4 migration](htmx_4_migration.md)
### Demos
diff --git a/docs/installation.md b/docs/installation.md
index b3b8a2e..e4871ea 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -3,7 +3,7 @@
- [Composer Installation](#composer-installation)
- [Manual Installation](#manual-installation)
-Remember - you still need to include the `htmx` javascript library inside the `head` tag.
+Remember - you still need to include the `htmx` JavaScript library inside the `head` tag. The current `develop` branch expects an HTMX 4 client.
## Composer Installation
@@ -37,4 +37,3 @@ public $files = [
];
```
-
diff --git a/docs/redirect_response.md b/docs/redirect_response.md
index 9557fb1..7a91ce7 100644
--- a/docs/redirect_response.md
+++ b/docs/redirect_response.md
@@ -13,9 +13,35 @@ Sets the `HX-Location` header to redirect without reloading the whole page.
```php
return redirect()->hxLocation('/path');
```
-For convenience, the set path with `http(s)://` will be converted to relative. Like this: `http://example.com/articles/` it will become `/articles/`.
+For convenience, absolute `http(s)://` paths are converted to relative paths. For example, `http://example.com/articles/` becomes `/articles/`.
-For more information, please see [hx-location](https://htmx.org/headers/hx-location/).
+Supported fields mirror the HTMX 4 `HX-Location` JSON payload:
+
+- `path` - required
+- `source`
+- `event`
+- `handler`
+- `target`
+- `swap`
+- `values`
+- `headers`
+- `select`
+- `push`
+- `replace`
+
+Example:
+
+```php
+return redirect()->hxLocation(
+ path: '/photos',
+ target: '#content',
+ swap: 'innerHTML',
+ select: '#photos-list',
+ push: '/photos',
+);
+```
+
+For more information, please see the HTMX 4 [HX-Location response header docs](https://four.htmx.org/reference/headers/hx-location/).
### hxRedirect()
diff --git a/docs/response.md b/docs/response.md
index ffbb95a..7c073fe 100644
--- a/docs/response.md
+++ b/docs/response.md
@@ -27,7 +27,7 @@ $this->response->setReplaceUrl('/replaced-url');
### setReswap()
-Sets the value in `HX-Reswap` header. Allows you to specify how the response will be swapped. See [hx-swap](https://htmx.org/attributes/hx-swap) for possible values.
+Sets the value in `HX-Reswap` header. Allows you to specify how the response will be swapped. See [hx-swap](https://four.htmx.org/reference/attributes/hx-swap/) for possible values.
```php
$this->response->setReswap('innerHTML show:#another-div:top');
@@ -43,7 +43,7 @@ $this->response->setRetarget('#another-div');
### setReselect()
-Sets the value in `HX-Reselect` header. A CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing [hx-select](https://htmx.org/attributes/hx-select/) on the triggering element.
+Sets the value in `HX-Reselect` header. A CSS selector that allows you to choose which part of the response is used to be swapped in. Overrides an existing [`hx-select`](https://four.htmx.org/reference/) on the triggering element.
```php
$this->response->setReselect('#another-div');
@@ -51,15 +51,12 @@ $this->response->setReselect('#another-div');
### triggerClientEvent()
-Allows you to set the headers: `HX-Trigger`, `HX-Trigger-After-Settle` or `HX-Trigger-After-Swap`.
+Allows you to set the `HX-Trigger` header.
-This method has 3 parameters:
+This method has 2 parameters:
* `name`
* `params`
-* `method` - which can be one of: `receive` (default), `settle`, `swap`.
```php
$this->response->triggerClientEvent('showMessage', ['level' => 'info', 'message' => 'Here Is A Message']);
```
-
-For more information, please see [hx-trigger](https://htmx.org/headers/hx-trigger/).
diff --git a/mkdocs.yml b/mkdocs.yml
index b9fb6bf..64f2b99 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -65,3 +65,4 @@ nav:
- HTML Formatter: html_formatter.md
- Debug Toolbar: debug_toolbar.md
- Troubleshooting: troubleshooting.md
+ - HTMX 4 migration: htmx_4_migration.md
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 00b7e7c..759763c 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -21,12 +21,6 @@ parameters:
count: 1
path: src/View/View.php
- -
- message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertArrayHasKey\(\) with ''_ci_previous_url'' and non\-empty\-array will always evaluate to true\.$#'
- identifier: method.alreadyNarrowedType
- count: 2
- path: tests/CodeIgniterTest.php
-
-
message: '''
#^Call to deprecated method __construct\(\) of class CodeIgniter\\HTTP\\Response\:
diff --git a/src/HTTP/HtmxTrait.php b/src/HTTP/HtmxTrait.php
index 15ab51a..61f118a 100644
--- a/src/HTTP/HtmxTrait.php
+++ b/src/HTTP/HtmxTrait.php
@@ -9,9 +9,16 @@ trait HtmxTrait
private array $swapOptions = [
'innerHTML',
'outerHTML',
+ 'innerMorph',
+ 'outerMorph',
+ 'textContent',
+ 'before',
'beforebegin',
+ 'prepend',
'afterbegin',
+ 'append',
'beforeend',
+ 'after',
'afterend',
'delete',
'none',
diff --git a/src/HTTP/IncomingRequest.php b/src/HTTP/IncomingRequest.php
index 5601b2e..553a2ef 100644
--- a/src/HTTP/IncomingRequest.php
+++ b/src/HTTP/IncomingRequest.php
@@ -8,6 +8,9 @@ class IncomingRequest extends BaseIncomingRequest
{
/**
* Indicates that the request is triggered by Htmx.
+ *
+ * Checks whether the request carries the HX-Request header
+ * with the value "true".
*/
public function isHtmx(): bool
{
@@ -23,8 +26,7 @@ public function isBoosted(): bool
}
/**
- * True if the request is for history restoration
- * after a miss in the local history cache.
+ * True if the request is for history restoration.
*/
public function isHistoryRestoreRequest(): bool
{
@@ -32,58 +34,62 @@ public function isHistoryRestoreRequest(): bool
}
/**
- * The current URL of the browser.
+ * The request type for HTMX 4 requests.
+ *
+ * Returns "partial" for targeted swaps and "full"
+ * for body-level swaps, including hx-boost requests,
+ * or requests using hx-select.
*/
- public function getCurrentUrl(): ?string
+ public function getRequestType(): ?string
{
- return $this->getHtmxHeader('HX-Current-Url');
+ return $this->getHtmxHeader('HX-Request-Type');
}
/**
- * The user response to an hx-prompt.
+ * Indicates a partial HTMX request.
*/
- public function getPrompt(): ?string
+ public function isPartial(): bool
{
- return $this->getHtmxHeader('HX-Prompt');
+ return $this->getRequestType() === 'partial';
}
/**
- * The id of the target element if it exists.
+ * Indicates a full HTMX request.
*/
- public function getTarget(): ?string
+ public function isFull(): bool
{
- return $this->getHtmxHeader('HX-Target');
+ return $this->getRequestType() === 'full';
}
/**
- * The id of the triggered element if it exists.
+ * The current URL of the browser.
*/
- public function getTrigger(): ?string
+ public function getCurrentUrl(): ?string
{
- return $this->getHtmxHeader('HX-Trigger');
+ return $this->getHtmxHeader('HX-Current-Url');
}
/**
- * The name of the triggered element if it exists.
+ * The identifier of the triggered element if it exists.
+ *
+ * In HTMX 4 this is sent in HX-Source and uses the
+ * element identifier format, such as "button#submit?send"
+ * or "div#results?".
*/
- public function getTriggerName(): ?string
+ public function getSource(): ?string
{
- return $this->getHtmxHeader('HX-Trigger-Name');
+ return $this->getHtmxHeader('HX-Source');
}
/**
- * The value of the header is a JSON serialized
- * version of the event that triggered the request.
+ * The identifier of the target element if it exists.
*
- * @see https://htmx.org/extensions/event-header/
+ * HTMX 4 uses the same element identifier format as HX-Source,
+ * for example "div#results?".
*/
- public function getTriggeringEvent(bool $toArray = true): array|object|null
+ public function getTarget(): ?string
{
- if (! $this->hasHeader('Triggering-Event')) {
- return null;
- }
-
- return json_decode($this->header('Triggering-Event')->getValueLine(), $toArray);
+ return $this->getHtmxHeader('HX-Target');
}
/**
@@ -110,8 +116,8 @@ private function getHtmxHeaderToBool(string $header): bool
/**
* Checks this request type.
*
- * @param string $type HTTP verb or 'json' or 'ajax' or 'htmx' or 'boosted'
- * @phpstan-param string|'get'|'post'|'put'|'delete'|'head'|'patch'|'options'|'json'|'ajax'|'htmx'|'boosted' $type
+ * @param string $type HTTP verb or 'json' or 'ajax' or 'htmx' or 'boosted' or 'partial' or 'full'
+ * @phpstan-param string|'get'|'post'|'put'|'delete'|'head'|'patch'|'options'|'json'|'ajax'|'htmx'|'boosted'|'partial'|'full' $type
*/
public function is(string $type): bool
{
@@ -125,6 +131,14 @@ public function is(string $type): bool
return $this->isBoosted();
}
+ if ($valueUpper === 'PARTIAL') {
+ return $this->isPartial();
+ }
+
+ if ($valueUpper === 'FULL') {
+ return $this->isFull();
+ }
+
return parent::is($type);
}
}
diff --git a/src/HTTP/RedirectResponse.php b/src/HTTP/RedirectResponse.php
index 8e5531f..b28dd08 100644
--- a/src/HTTP/RedirectResponse.php
+++ b/src/HTTP/RedirectResponse.php
@@ -20,12 +20,12 @@ public function hxLocation(
?string $swap = null,
?array $values = null,
?array $headers = null,
+ ?string $select = null,
+ false|string|null $push = null,
+ ?string $replace = null,
+ mixed $handler = null,
): RedirectResponse {
- if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
- $path = (string) service('uri', $path, false)->withScheme('')->setHost('');
- }
-
- $data = ['path' => '/' . ltrim($path, '/')];
+ $data = ['path' => $this->normalizeLocationPath($path)];
if ($source !== null) {
$data['source'] = $source;
@@ -52,6 +52,22 @@ public function hxLocation(
$data['headers'] = $headers;
}
+ if ($select !== null) {
+ $data['select'] = $select;
+ }
+
+ if ($push !== null) {
+ $data['push'] = $push === false ? false : $this->normalizeLocationPath($push);
+ }
+
+ if ($replace !== null) {
+ $data['replace'] = $this->normalizeLocationPath($replace);
+ }
+
+ if ($handler !== null) {
+ $data['handler'] = $handler;
+ }
+
return $this->setStatusCode(200)->setHeader('HX-Location', json_encode($data));
}
@@ -76,4 +92,13 @@ public function hxRefresh(): RedirectResponse
{
return $this->setStatusCode(200)->setHeader('HX-Refresh', 'true');
}
+
+ private function normalizeLocationPath(string $path): string
+ {
+ if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
+ $path = (string) service('uri', $path, false)->withScheme('')->setHost('');
+ }
+
+ return '/' . ltrim($path, '/');
+ }
}
diff --git a/src/HTTP/Response.php b/src/HTTP/Response.php
index 147d361..87e9f3a 100644
--- a/src/HTTP/Response.php
+++ b/src/HTTP/Response.php
@@ -66,14 +66,9 @@ public function setReselect(string $selector): Response
/**
* Allows you to trigger client side events.
*/
- public function triggerClientEvent(string $name, array|string $params = '', string $after = 'receive'): Response
+ public function triggerClientEvent(string $name, array|string $params = ''): Response
{
- $header = match ($after) {
- 'receive' => 'HX-Trigger',
- 'settle' => 'HX-Trigger-After-Settle',
- 'swap' => 'HX-Trigger-After-Swap',
- default => throw new InvalidArgumentException('A value for "after" argument must be one of: "receive", "settle", or "swap".'),
- };
+ $header = 'HX-Trigger';
if ($this->hasHeader($header)) {
$data = json_decode($this->header($header)->getValue(), true);
diff --git a/src/View/error_modal_decorator.js b/src/View/error_modal_decorator.js
index bb2cec8..388cad4 100644
--- a/src/View/error_modal_decorator.js
+++ b/src/View/error_modal_decorator.js
@@ -1,44 +1,200 @@
if (typeof window.htmx !== 'undefined') {
- htmx.on('htmx:responseError', function (event) {
- const xhr = event.detail.xhr;
-
- event.stopPropagation();
-
- // Create modal
- const htmxErrorModal = document.createElement('div');
- htmxErrorModal.id = 'htmxErrorModal'
- // Set title
- const htmxErrorModalTitle = document.createElement('h2');
- const htmxErrorModalTitleContent = document.createTextNode('Error: ' + xhr.status);
- htmxErrorModalTitle.appendChild(htmxErrorModalTitleContent);
-
- // Set close buton
- const htmxErrorModalCloseButton = document.createElement('button');
- const htmxErrorModalCloseButtonContent = document.createTextNode('X');
- htmxErrorModalCloseButton.appendChild(htmxErrorModalCloseButtonContent);
- htmxErrorModalCloseButton.id = 'htmxErrorModalCloseButton';
-
- // Set error content
- const htmxErrorModalContent = document.createElement('textarea');
- htmxErrorModalContent.innerHTML = xhr.response;
-
- // Set styles
- htmxErrorModal.setAttribute('style', 'position: absolute; max-width: 90%; left: 50%; transform: translateX(-50%); z-index: 99999; background: #fbe0e0; padding: 20px; border-radius: 5px; font-family: sans-serif; top: 50px;');
- htmxErrorModalTitle.setAttribute('style', 'display: inline-block;')
- htmxErrorModalCloseButton.setAttribute('style', 'border: 1px solid; padding: 5px 8px 3px 8px; display: inline-block; float: right;');
- htmxErrorModalContent.setAttribute('style', 'border: 1px solid #ccc; width: 80vw; height: 80vh');
-
- // Append content to modal
- htmxErrorModal.appendChild(htmxErrorModalTitle);
- htmxErrorModal.appendChild(htmxErrorModalCloseButton);
- htmxErrorModal.appendChild(htmxErrorModalContent);
-
- // Add modal to DOM
- document.body.appendChild(htmxErrorModal);
-
- // Handle close button
- htmxErrorModalCloseButton.onclick = function remove() {
- htmxErrorModal.parentElement.removeChild(htmxErrorModal);
+ const handledResponses = new WeakSet();
+
+ const isHtmlResponse = function (body, contentType) {
+ if (typeof contentType === 'string') {
+ const normalizedContentType = contentType.toLowerCase();
+
+ if (
+ normalizedContentType.includes('text/html')
+ || normalizedContentType.includes('application/xhtml+xml')
+ ) {
+ return true;
+ }
}
+
+ const trimmedBody = body.trim().toLowerCase();
+
+ return trimmedBody.startsWith('= 400) {
+ showFetchResponseError(ctx.response);
+ event.preventDefault();
+ }
+ });
+
+ htmx.on('htmx:error', function (event) {
+ const detail = event.detail || {};
+ const ctx = detail.ctx || {};
+
+ if (ctx.response) {
+ if (ctx.response.raw && handledResponses.has(ctx.response.raw)) {
+ return;
+ }
+
+ showFetchResponseError(ctx.response);
+ return;
+ }
+
+ showModal(ctx.status || 'request error', detail.error ? String(detail.error) : '', '');
});
+
}
diff --git a/src/View/toolbar_decorator.js b/src/View/toolbar_decorator.js
index ffbc9b2..c9a4211 100644
--- a/src/View/toolbar_decorator.js
+++ b/src/View/toolbar_decorator.js
@@ -1,10 +1,32 @@
if (typeof window.htmx !== 'undefined' &&
typeof window.loadDoc !== 'undefined' &&
document.getElementById('debugbar_dynamic_script')) {
- htmx.on('htmx:afterSettle', function (event) {
- let debugBarTime = event.detail.xhr.getResponseHeader('debugbar-time');
- if (debugBarTime !== null) {
+ let lastDebugBarTime = null;
+
+ const getDebugBarTime = function (event) {
+ const detail = event.detail || {};
+ const response = detail.ctx && detail.ctx.response ? detail.ctx.response : null;
+
+ if (response !== null && response.headers && typeof response.headers.get === 'function') {
+ return response.headers.get('debugbar-time');
+ }
+
+ if (detail.xhr && typeof detail.xhr.getResponseHeader === 'function') {
+ return detail.xhr.getResponseHeader('debugbar-time');
+ }
+
+ return null;
+ };
+
+ const refreshDebugBar = function (event) {
+ const debugBarTime = getDebugBarTime(event);
+
+ if (debugBarTime !== null && debugBarTime !== lastDebugBarTime) {
+ lastDebugBarTime = debugBarTime;
loadDoc(debugBarTime);
}
- });
+ };
+
+ htmx.on('htmx:after:request', refreshDebugBar);
+ htmx.on('htmx:after:settle', refreshDebugBar);
}
diff --git a/tests/CodeIgniterTest.php b/tests/CodeIgniterTest.php
index 676795f..04d99de 100644
--- a/tests/CodeIgniterTest.php
+++ b/tests/CodeIgniterTest.php
@@ -39,7 +39,6 @@ public function testStorePreviousURLIsHTMX(): void
ob_get_clean();
$this->assertTrue(service('request')->isHTMX());
- $this->assertArrayHasKey('_ci_previous_url', $_SESSION);
$this->assertSame('https://example.com/index.php/?previous=saved_from_htmx', $_SESSION['_ci_previous_url']);
}
@@ -59,7 +58,6 @@ public function testDontStorePreviousURLIsHTMX(): void
ob_get_clean();
$this->assertTrue(service('request')->isHTMX());
- $this->assertArrayHasKey('_ci_previous_url', $_SESSION);
$this->assertSame('https://example.com/index.php/?previous=original', $_SESSION['_ci_previous_url']);
}
}
diff --git a/tests/HTTP/IncomingRequestTest.php b/tests/HTTP/IncomingRequestTest.php
index 4780442..71fa706 100644
--- a/tests/HTTP/IncomingRequestTest.php
+++ b/tests/HTTP/IncomingRequestTest.php
@@ -67,76 +67,66 @@ public function testIsHistoryRestoreRequestIsFalse(): void
$this->assertFalse($this->request->isHistoryRestoreRequest());
}
- public function testGetCurrentUrl(): void
+ public function testGetRequestType(): void
{
- $header = 'https://codeigniter-htmx-demo.test/';
- $this->request->appendHeader('HX-Current-Url', $header);
- $this->assertSame($header, $this->request->getCurrentUrl());
+ $header = 'partial';
+ $this->request->appendHeader('HX-Request-Type', $header);
+ $this->assertSame($header, $this->request->getRequestType());
}
- public function testGetCurrentUrlIsNull(): void
+ public function testGetRequestTypeIsNull(): void
{
- $this->assertNull($this->request->getCurrentUrl());
+ $this->assertNull($this->request->getRequestType());
}
- public function testGetPrompt(): void
+ public function testIsPartial(): void
{
- $header = 'prompt test';
- $this->request->appendHeader('HX-Prompt', $header);
- $this->assertSame($header, $this->request->getPrompt());
+ $this->request->appendHeader('HX-Request-Type', 'partial');
+ $this->assertTrue($this->request->isPartial());
+ $this->assertFalse($this->request->isFull());
}
- public function testGetPromptIsNull(): void
+ public function testIsFull(): void
{
- $this->assertNull($this->request->getPrompt());
- }
-
- public function testGetTarget(): void
- {
- $header = '#response-div';
- $this->request->appendHeader('HX-Target', $header);
- $this->assertSame($header, $this->request->getTarget());
- }
-
- public function testGetTargetIsNull(): void
- {
- $this->assertNull($this->request->getTarget());
+ $this->request->appendHeader('HX-Request-Type', 'full');
+ $this->assertTrue($this->request->isFull());
+ $this->assertFalse($this->request->isPartial());
}
- public function testGetTrigger(): void
+ public function testGetCurrentUrl(): void
{
- $header = 'test-id';
- $this->request->appendHeader('HX-Trigger', $header);
- $this->assertSame($header, $this->request->getTrigger());
+ $header = 'https://codeigniter-htmx-demo.test/';
+ $this->request->appendHeader('HX-Current-Url', $header);
+ $this->assertSame($header, $this->request->getCurrentUrl());
}
- public function testGetTriggerIsNull(): void
+ public function testGetCurrentUrlIsNull(): void
{
- $this->assertNull($this->request->getTrigger());
+ $this->assertNull($this->request->getCurrentUrl());
}
- public function testGetTriggerName(): void
+ public function testGetTarget(): void
{
- $header = 'test-name';
- $this->request->appendHeader('HX-Trigger-Name', $header);
- $this->assertSame($header, $this->request->getTriggerName());
+ $header = 'div#response-div';
+ $this->request->appendHeader('HX-Target', $header);
+ $this->assertSame($header, $this->request->getTarget());
}
- public function testGetTriggerNameIsNull(): void
+ public function testGetTargetIsNull(): void
{
- $this->assertNull($this->request->getTriggerName());
+ $this->assertNull($this->request->getTarget());
}
- public function testGetTriggeringEvent(): void
+ public function testGetSource(): void
{
- $header = '{"isTrusted":true,"htmx-internal-data":{"triggerSpec":{"trigger":"click"},"handledFor":["button.btn.btn-sm btn-primary"]},"screenX":1347,"screenY":238,"pageX":106,"pageY":128,"clientX":106,"clientY":128,"x":106,"y":128,"offsetX":93,"offsetY":11,"ctrlKey":false,"shiftKey":false,"altKey":false,"metaKey":false,"button":0,"buttons":0,"relatedTarget":null,"movementX":0,"movementY":0,"mozPressure":0,"mozInputSource":1,"MOZ_SOURCE_UNKNOWN":0,"MOZ_SOURCE_MOUSE":1,"MOZ_SOURCE_PEN":2,"MOZ_SOURCE_ERASER":3,"MOZ_SOURCEā¦ck","target":"button.btn.btn-sm btn-primary","srcElement":"button.btn.btn-sm btn-primary","currentTarget":"button.btn.btn-sm btn-primary","eventPhase":2,"bubbles":true,"cancelable":true,"returnValue":true,"defaultPrevented":false,"composed":true,"timeStamp":1599,"cancelBubble":false,"originalTarget":"button.btn.btn-sm btn-primary","explicitOriginalTarget":"button.btn.btn-sm btn-primary","NONE":0,"CAPTURING_PHASE":1,"AT_TARGET":2,"BUBBLING_PHASE":3,"ALT_MASK":1,"CONTROL_MASK":2,"SHIFT_MASK":4,"META_MASK":8}';
- $this->request->appendHeader('Triggering-Event', $header);
- $this->assertSame(json_decode($header), $this->request->getTriggeringEvent());
+ $header = 'button#test-id';
+ $this->request->appendHeader('HX-Source', $header);
+ $this->assertSame($header, $this->request->getSource());
}
- public function testGetTriggeringEventIsNull(): void
+ public function testGetSourceIsNull(): void
{
- $this->assertNull($this->request->getTriggeringEvent());
+ $this->assertNull($this->request->getSource());
}
public function testIsMethodWithHtmxParam(): void
@@ -151,6 +141,18 @@ public function testIsMethodWithBoostedParam(): void
$this->assertTrue($request->is('boosted'));
}
+ public function testIsMethodWithPartialParam(): void
+ {
+ $request = $this->request->setHeader('HX-Request-Type', 'partial');
+ $this->assertTrue($request->is('partial'));
+ }
+
+ public function testIsMethodWithFullParam(): void
+ {
+ $request = $this->request->setHeader('HX-Request-Type', 'full');
+ $this->assertTrue($request->is('full'));
+ }
+
public function testIsMethodWithInvalidParam(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/HTTP/RedirectResponseTest.php b/tests/HTTP/RedirectResponseTest.php
index 8af2e12..02f8ec8 100644
--- a/tests/HTTP/RedirectResponseTest.php
+++ b/tests/HTTP/RedirectResponseTest.php
@@ -78,6 +78,54 @@ public function testHxLocationWithValuesAndHeaders(): void
$this->assertSame(200, $this->response->getStatusCode());
}
+ public function testHxLocationWithSelectPushReplaceAndHandler(): void
+ {
+ $this->response = $this->response->hxLocation(
+ path: '/foo',
+ select: '#fragment',
+ push: '/pushed',
+ replace: '/replaced',
+ handler: 'customHandler',
+ );
+
+ $this->assertTrue($this->response->hasHeader('HX-Location'));
+ $expected = json_encode([
+ 'path' => '/foo',
+ 'select' => '#fragment',
+ 'push' => '/pushed',
+ 'replace' => '/replaced',
+ 'handler' => 'customHandler',
+ ]);
+ $this->assertSame($expected, $this->response->getHeaderLine('HX-Location'));
+ $this->assertSame(200, $this->response->getStatusCode());
+ }
+
+ public function testHxLocationWithPushFalse(): void
+ {
+ $this->response = $this->response->hxLocation(path: '/foo', push: false);
+
+ $this->assertSame(
+ json_encode(['path' => '/foo', 'push' => false]),
+ $this->response->getHeaderLine('HX-Location'),
+ );
+ }
+
+ public function testHxLocationNormalizesPushAndReplaceFullUrl(): void
+ {
+ $this->response = $this->response->hxLocation(
+ path: '/foo',
+ push: 'https://example.com/pushed?page=1#top',
+ replace: 'https://example.com/replaced?sort=asc',
+ );
+
+ $expected = json_encode([
+ 'path' => '/foo',
+ 'push' => '/pushed?page=1#top',
+ 'replace' => '/replaced?sort=asc',
+ ]);
+ $this->assertSame($expected, $this->response->getHeaderLine('HX-Location'));
+ }
+
public function testHxLocationThrowInvalidArgumentException(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/HTTP/ResponseTest.php b/tests/HTTP/ResponseTest.php
index 3757917..538f30c 100644
--- a/tests/HTTP/ResponseTest.php
+++ b/tests/HTTP/ResponseTest.php
@@ -65,6 +65,27 @@ public function testSetReswapWithModifier(): void
$this->assertSame('innerHTML swap:1s', $this->response->getHeaderLine('HX-Reswap'));
}
+ public function testSetReswapWithTextContent(): void
+ {
+ $this->response->setReswap('textContent');
+
+ $this->assertSame('textContent', $this->response->getHeaderLine('HX-Reswap'));
+ }
+
+ public function testSetReswapWithAlias(): void
+ {
+ $this->response->setReswap('append');
+
+ $this->assertSame('append', $this->response->getHeaderLine('HX-Reswap'));
+ }
+
+ public function testSetReswapWithMorph(): void
+ {
+ $this->response->setReswap('innerMorph');
+
+ $this->assertSame('innerMorph', $this->response->getHeaderLine('HX-Reswap'));
+ }
+
public function testSetReswapThrowInvalidArgumentException(): void
{
$this->expectException(InvalidArgumentException::class);
@@ -117,33 +138,6 @@ public function testTriggerClientEventAndPassDetailsMultipleCalls(): void
);
}
- public function testTriggerClientEventWithSettle(): void
- {
- $this->response->triggerClientEvent('showMessage', '', 'settle');
-
- $this->assertSame(
- '{"showMessage":""}',
- $this->response->getHeaderLine('HX-Trigger-After-Settle'),
- );
- }
-
- public function testTriggerClientEventWithSwap(): void
- {
- $this->response->triggerClientEvent('showMessage', '', 'swap');
-
- $this->assertSame(
- '{"showMessage":""}',
- $this->response->getHeaderLine('HX-Trigger-After-Swap'),
- );
- }
-
- public function testTriggerClientEventThrowInvalidArgumentException(): void
- {
- $this->expectException(InvalidArgumentException::class);
-
- $this->response->triggerClientEvent('event1', 'A message', 'foo');
- }
-
public function testTriggerClientEventThrowInvalidArgumentExceptionForHeaderContent(): void
{
$this->expectException(InvalidArgumentException::class);