diff --git a/Guide/auto-refresh.markdown b/Guide/auto-refresh.markdown index 4877f175f..10bdf6b47 100644 --- a/Guide/auto-refresh.markdown +++ b/Guide/auto-refresh.markdown @@ -98,7 +98,6 @@ action MyAction = do -- <-- We don't enable auto refresh at the action start in render MyView { expensiveModels, cheap } ``` -### Custom SQL Queries with Auto Refresh Auto Refresh automatically tracks all tables your action is using by hooking itself into the Query Builder and `fetch` functions. @@ -124,3 +123,159 @@ action StatsAction = autoRefresh do ``` The [`trackTableRead`](https://ihp.digitallyinduced.com/api-docs/IHP-ModelSupport.html#v:trackTableRead) marks the table as accessed for Auto Refresh and leads to the table being watched. + +### Using Auto Refresh with HTMX + +HTMX and Auto Refresh work well together: + +- HTMX loads/replaces fragments in response to user interactions +- Auto Refresh keeps those fragments up-to-date when database rows change + +For pages that use HTMX fragments, include the HTMX-specific Auto Refresh client: + +```haskell +scripts :: Html +scripts = [hsx| + + + + + + |] +``` + +`/helpers-htmx.js` + `/ihp-auto-refresh-htmx.js` is the HTMX equivalent of the classic +`/helpers.js` + `/ihp-auto-refresh.js` setup: + +- `helpers-htmx.js` handles HTMX morphdom swaps and helper compatibility (`ihp:load`, `ihp:unload`, date/time formatting, flatpickr init, toggle/back/file preview helpers, alert dismiss on request) +- `ihp-auto-refresh-htmx.js` handles Auto Refresh WebSocket sessions, target-based fragment updates, and pause/resume around HTMX requests + +Keep this script order: + +1. `htmx.min.js` +2. `morphdom-umd.min.js` +3. `helpers-htmx.js` +4. `ihp-auto-refresh-htmx.js` +5. `app.js` + +Use `/ihp-auto-refresh.js` for full-page morphing without HTMX. +Use `/ihp-auto-refresh-htmx.js` when HTMX controls fragment swaps. +Do not include both scripts on the same page. +Also do not include both `/helpers.js` and `/helpers-htmx.js` on the same page. + +For HTMX fragment actions, prefer `renderFragment`. It skips the layout and includes the Auto Refresh meta tag. + +#### End-to-end example + +Let's say we want a project page where comments are loaded by HTMX and then kept live by Auto Refresh. + +In the parent page we render a target container with a stable `id`: + +```haskell +[hsx| +
+
Loading comments ...
+
+|] +``` + +This is the recommended default setup. With a stable target `id`, Auto Refresh can usually infer the correct target automatically. + +The fragment action enables Auto Refresh and renders only the fragment content: + +```haskell +action CommentsFragmentAction { projectId } = autoRefresh do + comments <- query @Comment + |> filterWhere (#projectId, projectId) + |> orderByDesc #createdAt + |> fetch + renderFragment CommentsFragmentView { .. } +``` + +The fragment view: + +```haskell +instance View CommentsFragmentView where + html CommentsFragmentView { comments } = [hsx| + {forEach comments renderComment} + |] +``` + +Whenever a comment row changes, Auto Refresh re-runs the fragment action and morphs the target container. + +`renderFragment` renders the view without layout and prepends `autoRefreshMeta`, so you usually don't need to include +`{autoRefreshMeta}` manually in HTMX fragment responses. + +Write actions can simply return `204` and let Auto Refresh update the fragment: + +```haskell +action CreateCommentAction = do + let comment = newRecord @Comment + comment + |> fill @'["projectId", "body"] + |> ifValid \case + Left _ -> respondAndExitWithHeaders (responseLBS status422 [] "") + Right validComment -> do + validComment |> createRecord + respondAndExitWithHeaders (responseLBS status204 [] "") +``` + +#### Choosing the update target + +In practice, a **stable `id`** means: + +- The element that receives the HTMX swap has an `id` +- The same `id` is kept across all swaps +- There is only one element with that `id` on the page +- With `hx-swap="innerHTML"`, this `id` is on the outer container (the response fragment itself does not need that `id`) + +To keep fragment updates predictable: + +1. Prefer a stable `id` on your HTMX target container +2. Keep that `id` unchanged across swaps +3. If no stable `id` is available, Auto Refresh falls back to full-page updates + +As long as HTMX swaps HTML into a target element, the common verbs (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) work the same way. + +#### What is handled well + +- `hx-swap="innerHTML"` with a target element that has a stable `id` +- `hx-swap="outerHTML"` when the returned element still has the same stable `id` +- Fragments loaded by `hx-get`, and later updated by any HTMX verb, as long as swaps happen into a resolvable target +- Pages with multiple fragments, each with its own target and action + +#### Cases to avoid (or configure explicitly) + +- No target `id`: updates fall back to full-page morphing +- `hx-swap="none"` (or responses that do not swap HTML): no target can be inferred from the swap +- Changing/removing the target selector over time (for example changing `id` between swaps): updates can stop applying +- Duplicate `id`s for swap targets: update behavior becomes unpredictable + +#### Multiple fragments on the same page + +You can have multiple independent HTMX + Auto Refresh fragments on one page. Give each fragment: + +1. Its own swap target +2. Its own action +3. A stable `id` on the swap target + +```haskell +[hsx| +
+
+|] +``` + +Each fragment gets its own Auto Refresh session and updates independently. + +#### Common pitfalls + +- Do not include both `/ihp-auto-refresh.js` and `/ihp-auto-refresh-htmx.js` +- With `hx-swap="innerHTML"`, return only inner content, not another wrapper with the same `id` +- Keep the target container stable across renders so morphdom can preserve `hx-*` attributes and input state +- If updates affect too much UI, split the page into smaller HTMX fragments or switch to `autoRefreshWith` filtering diff --git a/Guide/htmx-and-hyperscript.markdown b/Guide/htmx-and-hyperscript.markdown index 3657dbeb5..3a5b926fc 100644 --- a/Guide/htmx-and-hyperscript.markdown +++ b/Guide/htmx-and-hyperscript.markdown @@ -51,6 +51,141 @@ scripts = [hsx| |] ``` +The `helpers.js` script from ihp can interfer with htmx, but ihp provides a `/helpers-htmx.js` and `/ihp-auto-refresh-htmx.js` that are drop in replacement for working with htmx. + +### Replacing `helpers.js` with HTMX + morphdom + +You can remove `helpers.js` and use HTMX + `helpers-htmx.js` instead. + +For boosted `` and `
` navigation, set HTMX attributes on the layout root: + +1. Add HTMX and morphdom scripts to your layout. +2. Add IHP's HTMX helper script (`/helpers-htmx.js`). +3. Set `hx-boost`, `hx-target`, `hx-select`, and `hx-swap` on your layout root so boosted links/forms patch only the page content container. + +```haskell +defaultLayout :: Html -> Html +defaultLayout inner = [hsx| + + + + {metaTags} + {stylesheets} + {scripts} + {pageTitleOrDefault "App"} + + +
+ {renderFlashMessages} + {inner} +
+ + +|] +``` + +```haskell +scripts :: Html +scripts = [hsx| + {when isDevelopment devScripts} + ... + + + + + + |] +``` + +`/helpers-htmx.js` is designed to be used together with `/ihp-auto-refresh-htmx.js`. + +- `helpers-htmx.js` provides HTMX morphdom swap behavior and helper compatibility hooks +- `ihp-auto-refresh-htmx.js` provides Auto Refresh session management and fragment updates + +Do not mix legacy and HTMX variants on the same page: + +- Not together: `/helpers.js` and `/helpers-htmx.js` +- Not together: `/ihp-auto-refresh.js` and `/ihp-auto-refresh-htmx.js` + + +| Difference | What official extension does | What `helpers-htmx.js` does | Do you need this? | +| --- | --- | --- | --- | +| Input/textarea/option state handling | Relies on default morphdom behavior | Explicitly keeps current client state for `input`, `textarea`, `option` during swaps | Recommended when users can be typing while live updates arrive (Auto Refresh, concurrent updates) | +| File input patch safety | No explicit guard for `input[type=file]` | Skips updating file inputs during morph to avoid losing selected files | Recommended if forms with file uploads can be swapped | +| Node key strategy | No custom `getNodeKey` | Uses stable keys (`id`, script `src`) for better identity matching | Recommended for complex/reordered DOM and script stability | +| HTMX processing after custom swap | Returns swapped nodes to HTMX; no explicit `htmx.process` call in extension source | Calls `htmx.process(target)` explicitly after swap | Defensive behavior; usually safe either way | + +Use the official extension if you want minimal behavior and default morphdom semantics. +Use `helpers-htmx.js` if you want a closer replacement for IHP helper behavior with fewer edge-case regressions. +In IHP docs and defaults, `helpers-htmx.js` is the standard morphdom swap path. + +The source is available as an IHP built-in static file at `ihp/data/static/helpers-htmx.js`. +If you want to vendor it into your app, copy `${IHP}/static/helpers-htmx.js` into your project's `static/` directory. +If you're using `make static/prod.js` bundling, add `JS_FILES += ${IHP}/static/helpers-htmx.js` to your app `Makefile`. + +With this setup, HTMX boost handles `
` and `` requests and morphdom keeps DOM identity stable (e.g. input values and cursor position). + +If you use Auto Refresh with HTMX, use `/ihp-auto-refresh-htmx.js`. + +#### `helpers.js` feature-by-feature mapping + +| `helpers.js` feature | HTMX / new script equivalent | Status | +| --- | --- | --- | +| Morphdom page/fragment patching (`transitionToNewPage`) | `hx-swap="morphdom"` + `/helpers-htmx.js` | Covered | +| Stable node matching (`getNodeKey`) | Implemented in `/helpers-htmx.js` (`id`, script `src`) | Covered | +| Preserve input value/cursor/checked/selected during swaps | Implemented inside `/helpers-htmx.js` | Covered | +| HTMX re-processing after morphdom swap | Implemented inside `/helpers-htmx.js` via `htmx.process` | Covered | +| Intercept links/forms and submit via AJAX (`initDisableButtonsOnSubmit` + `submitForm`) | `hx-boost="true"` on layout/body | Covered | +| Per-form opt-out of AJAX (`disableJavascriptSubmission`) | `hx-boost="false"` on the form (or parent scope) | Covered with config | +| Follow submitter semantics (`formAction`, clicked button value) | Standard form submitter behavior with HTMX-boosted forms | Covered | +| File uploads via form submit | Native form multipart behavior (`enctype="multipart/form-data"` or `hx-encoding`) | Covered with config | +| Delete links with confirmation (`.js-delete`, `.js-delete-no-confirm`) | `hx-delete` + `hx-confirm` | Covered | +| Disable submit buttons while request is in-flight | `hx-disabled-elt` | Covered | +| Dismiss `.alert` on submit | Built into `/helpers-htmx.js` (`htmx:beforeRequest`) | Covered | +| Pause auto refresh around requests | `/ihp-auto-refresh-htmx.js` already hooks into `htmx:beforeRequest`/`htmx:afterRequest` | Covered | +| Move/refresh auto-refresh session metadata across HTMX swaps | `/ihp-auto-refresh-htmx.js` handles meta harvesting + target inference | Covered | +| URL/history updates after boosted requests | `hx-push-url="true"` where needed (especially GET filter/search forms) | Covered with config | +| Special modal behavior in body morph (`modal-open` + `#main-row` guard) | Usually avoid by targeting a stable content container (`#page-content`) instead of full-body swaps; use `hx-preserve` for modal roots if needed | App-specific behavior | +| `.js-back` helper | Built into `/helpers-htmx.js` (`.js-back`, `[data-js-back]`) | Covered | +| `[data-toggle]` helper for disabling/enabling other fields | Built into `/helpers-htmx.js` | Covered | +| File upload preview (`input[type="file"][data-preview]`) | Built into `/helpers-htmx.js` | Covered | +| Date/time pretty formatting (`.time-ago`, `.date-time`, `.date`, `.time`) | Built into `/helpers-htmx.js` | Covered | +| Flatpickr auto-init on date fields | Built into `/helpers-htmx.js` | Covered | +| `.js-scroll-into-view` after navigation | Built into `/helpers-htmx.js` | Covered | +| `ihp:load` / `ihp:unload` events | Built into `/helpers-htmx.js` | Covered | +| Timer cleanup utilities (`clearAllIntervals` / `clearAllTimeouts`) | Built into `/helpers-htmx.js` | Covered | + +#### Detailed migration recipes + +That means you should not need to re-implement these in `app.js`: + +1. `.js-back` (and `[data-js-back]`) back button handling +2. `[data-toggle]` dependent field enable/disable behavior +3. `input[type=file][data-preview]` preview behavior +4. Date/time formatting (`.time-ago`, `.date-time`, `.date`, `.time`) +5. Flatpickr auto-initialization on initial load and HTMX swaps +6. `.js-scroll-into-view` behavior after swaps +7. `.alert` dismissal on HTMX form requests +8. `ihp:load` and `ihp:unload` compatibility events + +HTMX-native features still use HTMX attributes: + +1. Delete links: `hx-delete` (+ `hx-confirm` if needed) +2. Disable submit while requesting: `hx-disabled-elt` +3. Push URL for GET forms: `hx-push-url="true"` +4. Disable boost per form: `hx-boost="false"` +5. Multipart uploads: `enctype="multipart/form-data"` (optionally `hx-encoding="multipart/form-data"`) + +#### Notes on remaining gaps + +With `helpers-htmx.js` plus HTMX-native attributes (`hx-delete`, `hx-disabled-elt`, `hx-push-url`, etc.), the common `helpers.js` migration surface is covered. + ### htmx usage Assume the simple controller as an example: @@ -65,7 +200,12 @@ data CounterController Add `parseRoute @CounterController` to the list of `instance FrontController WebApplication` in `FrontController.hs` (or you'll get a 404 on calling it), and add an `instance AutoRoute CounterController` to `Routes.hs` (or you'll get a compilation error about it not being an instance of AutoRoute). -Instead of using the `render` function, htmx routes are better used with `respondHtml` to avoid the layout being shipped as part of the response. The same function can be used for initializing the view as well as updating. +For HTMX endpoints, use different render helpers depending on the response: + +- Full page responses: use `render` +- Fragment responses: use `respondHtmlFragment` (or `renderFragment` when returning a `View`) + +This avoids shipping the layout in fragment responses. ```haskell module Web.Controller.Counter where @@ -85,15 +225,15 @@ instance Controller CounterController where action IncrementCountAction{counterId} = do counter <- fetch counterId updatedCounter <- counter |> incrementField #count |> updateRecord - respondHtml $ counterView updatedCounter + respondHtmlFragment $ counterView updatedCounter action DecrementCountAction{counterId} = do counter <- fetch counterId updatedCounter <- counter |> decrementField #count |> updateRecord - respondHtml $ counterView updatedCounter + respondHtmlFragment $ counterView updatedCounter ``` -We define the `CounterView` like this, separating the `counterView` function into a function that can be used be the initial view as well as the updater routes (`IncrementCountAction` and `DecrementCountAction`). +We define the `CounterView` like this, separating `counterView` into a reusable function for both the initial page and the fragment update routes (`IncrementCountAction` and `DecrementCountAction`). ```haskell module Web.View.Counter.Counter where diff --git a/ihp/IHP/Controller/Render.hs b/ihp/IHP/Controller/Render.hs index e1658088c..7b3d7124d 100644 --- a/ihp/IHP/Controller/Render.hs +++ b/ihp/IHP/Controller/Render.hs @@ -1,10 +1,10 @@ {-# LANGUAGE BangPatterns #-} module IHP.Controller.Render where import ClassyPrelude -import Network.Wai +import qualified Data.ByteString.Lazy +import Network.Wai (responseLBS, responseBuilder, responseFile) import Network.HTTP.Types (Status, status200, status406) import Network.HTTP.Types.Header -import qualified Data.ByteString.Lazy import qualified IHP.ViewSupport as ViewSupport import qualified Data.Aeson import IHP.ControllerSupport @@ -13,6 +13,7 @@ import qualified Network.HTTP.Media as Accept import qualified Text.Blaze.Html.Renderer.Utf8 as Blaze import Text.Blaze.Html (Html) +import IHP.AutoRefresh.View (autoRefreshMeta) import qualified IHP.Controller.Context as Context import IHP.Controller.Layout import IHP.FlashMessages (consumeFlashMessagesMiddleware) @@ -33,6 +34,24 @@ respondHtml html = do respondAndExitWithHeaders $ responseLBS status200 [(hContentType, "text/html; charset=utf-8"), (hConnection, "keep-alive")] bs {-# INLINE respondHtml #-} +-- | Like 'respondHtml', but always prepends 'autoRefreshMeta' to the response body. +-- +-- Intended for fragment-style responses (e.g. HTMX) where a full layout is not rendered. +respondHtmlFragment :: (?context :: ControllerContext, ?request :: Request) => Html -> IO () +respondHtmlFragment html = do + let !bs = Blaze.renderHtml html + frozenContext <- Context.freeze ?context + let meta = let ?context = frozenContext in Blaze.renderHtml autoRefreshMeta + let bs' = meta <> bs + -- We force the full evaluation of the blaze html to catch any runtime errors + -- with the IHP error middleware. Without this, certain thunks might only cause + -- an error when warp is building the response string. But then it's already too + -- late to catch the exception and the user will only get the default warp error + -- message instead of our nice IHP error message design. + _ <- evaluate (Data.ByteString.Lazy.length bs') + respondAndExitWithHeaders $ responseLBS status200 [(hContentType, "text/html; charset=utf-8"), (hConnection, "keep-alive")] bs' +{-# INLINE respondHtmlFragment #-} + respondSvg :: (?request :: Request) => Html -> IO () respondSvg html = respondAndExitWithHeaders $ responseBuilder status200 [(hContentType, "image/svg+xml"), (hConnection, "keep-alive")] (Blaze.renderHtmlBuilder html) {-# INLINABLE respondSvg #-} @@ -50,6 +69,19 @@ renderHtml !view = do pure boundHtml {-# INLINE renderHtml #-} +-- | Like 'renderHtml', but does not apply the current layout. +-- +-- Useful for endpoint fragments that should return only partial HTML. +renderHtmlFragment :: forall view. (ViewSupport.View view, ?context :: ControllerContext, ?request :: Request) => view -> IO Html +renderHtmlFragment !view = do + let ?view = view + ViewSupport.beforeRender view + frozenContext <- Context.freeze ?context + + let ?context = frozenContext + pure (ViewSupport.html ?view) +{-# INLINE renderHtmlFragment #-} + renderFile :: (?request :: Request) => String -> ByteString -> IO () renderFile filePath contentType = respondAndExitWithHeaders $ responseFile status200 [(hContentType, contentType)] filePath Nothing {-# INLINE renderFile #-} @@ -113,6 +145,10 @@ renderPolymorphic PolymorphicRender { html, json } = do polymorphicRender :: PolymorphicRender polymorphicRender = PolymorphicRender Nothing Nothing +-- | Render a view fragment without layout and respond with 'autoRefreshMeta' prepended. +renderFragment :: forall view. (ViewSupport.View view, ?context :: ControllerContext, ?request :: Request) => view -> IO () +renderFragment !view = (renderHtmlFragment view) >>= respondHtmlFragment +{-# INLINE renderFragment #-} {-# INLINE render #-} render :: forall view. (ViewSupport.View view, ?context :: ControllerContext, ?request :: Request, ?respond :: Respond) => view -> IO () @@ -127,4 +163,3 @@ render !view = do pure () , json = Just $ renderJson (ViewSupport.json view) } - diff --git a/ihp/Test/Test/Controller/RenderSpec.hs b/ihp/Test/Test/Controller/RenderSpec.hs new file mode 100644 index 000000000..b36090a88 --- /dev/null +++ b/ihp/Test/Test/Controller/RenderSpec.hs @@ -0,0 +1,94 @@ +module Test.Controller.RenderSpec where + +import IHP.Prelude +import Test.Hspec +import IHP.Controller.Render (renderFragment, respondHtmlFragment) +import IHP.Controller.Response (ResponseException (..), responseHeadersVaultKey) +import IHP.AutoRefresh (autoRefreshStateVaultKey) +import IHP.AutoRefresh.Types (AutoRefreshState (..)) +import IHP.ViewPrelude +import IHP.Test.Mocking (responseBody) +import qualified Control.Exception as Exception +import qualified Data.Text as Text +import qualified Data.UUID as UUID +import qualified Data.Vault.Lazy as Vault +import qualified Network.Wai as Wai +import Wai.Request.Params.Middleware (RequestBody (..), requestBodyVaultKey) + +data FragmentView = FragmentView + +instance View FragmentView where + html FragmentView = [hsx|
Hi
|] + +tests :: Spec +tests = describe "IHP.Controller.Render" do + describe "respondHtmlFragment" do + it "prepends autoRefreshMeta when auto refresh is enabled" do + let sessionId = parseSessionId "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + request <- buildRequest (Just sessionId) + + response <- captureResponse request do + respondHtmlFragment [hsx|
Hello
|] + + body :: Text <- cs <$> responseBody response + let expectedMeta = " tshow sessionId <> "\">" + + body `shouldSatisfy` Text.isPrefixOf expectedMeta + body `shouldSatisfy` Text.isInfixOf "
Hello
" + + it "does not prepend autoRefreshMeta when auto refresh is disabled" do + request <- buildRequest Nothing + + response <- captureResponse request do + respondHtmlFragment [hsx|
Hello
|] + + body :: Text <- cs <$> responseBody response + body `shouldSatisfy` Text.isInfixOf "
Hello
" + body `shouldNotSatisfy` Text.isInfixOf "ihp-auto-refresh-id" + + describe "renderFragment" do + it "renders the fragment view content" do + request <- buildRequest Nothing + + response <- captureResponse request do + renderFragment FragmentView + + body :: Text <- cs <$> responseBody response + body `shouldSatisfy` Text.isInfixOf "
Hi
" + +buildRequest :: Maybe UUID -> IO Wai.Request +buildRequest autoRefreshSession = do + headersRef <- newIORef [] + + let withResponseHeaders = + Vault.insert responseHeadersVaultKey headersRef Vault.empty + let withRequestBody = + Vault.insert requestBodyVaultKey (FormBody [] [] "") withResponseHeaders + let withAutoRefreshState = + case autoRefreshSession of + Just sessionId -> Vault.insert autoRefreshStateVaultKey (AutoRefreshEnabled sessionId) withRequestBody + Nothing -> withRequestBody + + pure Wai.defaultRequest { Wai.vault = withAutoRefreshState } + +captureResponse + :: Wai.Request + -> ((?context :: ControllerContext, ?request :: Wai.Request) => IO ()) + -> IO Wai.Response +captureResponse request action = do + let ?request = request + context <- newControllerContext + let ?context = context + + result <- Exception.try action :: IO (Either ResponseException ()) + case result of + Left (ResponseException response) -> pure response + Right _ -> do + expectationFailure "Expected action to terminate via ResponseException" + error "unreachable" + +parseSessionId :: String -> UUID +parseSessionId value = + case UUID.fromString value of + Just sessionId -> sessionId + Nothing -> error ("Invalid UUID in test: " <> cs value) diff --git a/ihp/Test/Test/Main.hs b/ihp/Test/Test/Main.hs index e1f7246e7..ae523766e 100644 --- a/ihp/Test/Test/Main.hs +++ b/ihp/Test/Test/Main.hs @@ -13,6 +13,7 @@ import qualified Test.Controller.ParamSpec import qualified Test.Controller.CookieSpec import qualified Test.Controller.AccessDeniedSpec import qualified Test.Controller.NotFoundSpec +import qualified Test.Controller.RenderSpec import qualified Test.ModelSupportSpec import qualified Test.QueryBuilderSpec import qualified Test.RouterSupportSpec @@ -38,6 +39,7 @@ main = hspec do Test.Controller.ParamSpec.tests Test.Controller.AccessDeniedSpec.tests Test.Controller.NotFoundSpec.tests + Test.Controller.RenderSpec.tests Test.ModelSupportSpec.tests Test.QueryBuilderSpec.tests Test.RouterSupportSpec.tests diff --git a/ihp/data/static/helpers-htmx.js b/ihp/data/static/helpers-htmx.js new file mode 100644 index 000000000..ddf339206 --- /dev/null +++ b/ihp/data/static/helpers-htmx.js @@ -0,0 +1,436 @@ +(function () { + var ihpLoadEvent = new Event('ihp:load'); + var ihpUnloadEvent = new Event('ihp:unload'); + + function toArray(nodeList) { + return Array.prototype.slice.call(nodeList || []); + } + + function selectAll(root, selector) { + var scope = root || document; + var result = []; + + if (scope.matches && scope.matches(selector)) { + result.push(scope); + } + + if (scope.querySelectorAll) { + result = result.concat(toArray(scope.querySelectorAll(selector))); + } + + return result; + } + + function dispatchIhpLoad() { + document.dispatchEvent(ihpLoadEvent); + } + + function dispatchIhpUnload() { + document.dispatchEvent(ihpUnloadEvent); + } + + function clearTrackedTimers() { + if (typeof window.clearAllIntervals === 'function') { + window.clearAllIntervals(); + } + + if (typeof window.clearAllTimeouts === 'function') { + window.clearAllTimeouts(); + } + } + + function isBoostedPageSwap(event) { + return !!( + event && + event.detail && + event.detail.boosted && + event.detail.shouldSwap !== false + ); + } + + function applyToggleInput(input) { + var selector = input.getAttribute('data-toggle'); + if (!selector) { + return; + } + + toArray(document.querySelectorAll(selector)).forEach(function (el) { + if (!(el instanceof HTMLElement)) { + return; + } + + if (input.checked) { + el.removeAttribute('disabled'); + } else { + el.setAttribute('disabled', 'disabled'); + } + }); + } + + function handleToggleChange(event) { + applyToggleInput(event.currentTarget); + } + + function initToggle(root) { + selectAll(root, '[data-toggle]').forEach(function (input) { + if (!(input instanceof HTMLInputElement)) { + return; + } + + if (!input.__ihpToggleInitialized) { + input.addEventListener('change', handleToggleChange); + input.__ihpToggleInitialized = true; + } + + applyToggleInput(input); + }); + } + + function initTime(root) { + if (window.timeago) { + window.timeago().render(selectAll(root, '.time-ago')); + } + + selectAll(root, '.date-time').forEach(function (elem) { + var date = new Date(elem.dateTime); + elem.innerHTML = + date.toLocaleDateString() + + ', ' + + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }); + + selectAll(root, '.date').forEach(function (elem) { + var date = new Date(elem.dateTime); + elem.innerHTML = date.toLocaleDateString(); + }); + + selectAll(root, '.time').forEach(function (elem) { + var date = new Date(elem.dateTime); + elem.innerHTML = date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }); + } + + function handleFilePreviewChange(event) { + var input = event.currentTarget; + var previewSelector = input.getAttribute('data-preview'); + if (!previewSelector || !input.files || !input.files[0]) { + return; + } + + var target = document.querySelector(previewSelector); + if (!target) { + return; + } + + var reader = new FileReader(); + reader.onload = function (e) { + target.setAttribute('src', e.target.result); + }; + reader.readAsDataURL(input.files[0]); + } + + function initFileUploadPreview(root) { + selectAll(root, 'input[type="file"]').forEach(function (input) { + if (!(input instanceof HTMLInputElement)) { + return; + } + + if (!input.getAttribute('data-preview')) { + return; + } + + if (!input.__ihpFileUploadPreviewInitialized) { + input.addEventListener('change', handleFilePreviewChange); + input.__ihpFileUploadPreviewInitialized = true; + } + }); + } + + function initDatePicker(root) { + if (!('flatpickr' in window)) { + return; + } + + selectAll(root, "input[type='date']").forEach(function (el) { + if (el._flatpickr) { + return; + } + + var dateOptions = {}; + if (!el.dataset.altFormat) { + dateOptions.altFormat = 'd.m.y'; + } + if (!el.dataset.altInput) { + dateOptions.altInput = true; + } + + flatpickr(el, dateOptions); + }); + + selectAll(root, "input[type='datetime-local']").forEach(function (el) { + if (el._flatpickr) { + return; + } + + var datetimeOptions = {}; + if (!el.dataset.enableTime) { + datetimeOptions.enableTime = true; + } + if (!el.dataset.time_24hr) { + datetimeOptions.time_24hr = true; + } + if (!el.dataset.dateFormat) { + datetimeOptions.dateFormat = 'Z'; + } + if (!el.dataset.altFormat) { + datetimeOptions.altFormat = 'd.m.y, H:i'; + } + if (!el.dataset.altInput) { + datetimeOptions.altInput = true; + } + + flatpickr(el, datetimeOptions); + }); + } + + function initScrollIntoView(root) { + var delay = window.unsafeSetTimeout || window.setTimeout; + delay(function () { + selectAll(root, '.js-scroll-into-view').forEach(function (el) { + if (el && el.scrollIntoView) { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }, 1); + } + + function handleBackClick(event) { + event.preventDefault(); + var element = event.currentTarget; + element.setAttribute('disabled', 'disabled'); + element.classList.add('disabled'); + window.history.back(); + } + + function initBack(root) { + selectAll(root, '.js-back, [data-js-back]').forEach(function (element) { + if (element.__ihpBackInitialized) { + return; + } + + if (element instanceof HTMLButtonElement || element.hasAttribute('data-js-back')) { + element.addEventListener('click', handleBackClick); + } else if (element instanceof HTMLAnchorElement && element.classList.contains('js-back')) { + console.error( + 'js-back does not supports
elements, use a