reitit/modules/reitit-frontend/src/reitit/frontend/history.cljs

302 lines
13 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(ns reitit.frontend.history
"Provides integration to hash-change or HTML5 History
events."
(:require [goog.events :as gevents]
[reitit.core :as reitit]
[reitit.frontend :as rf]
goog.Uri))
(defprotocol History
(-init [this] "Create event listeners")
(-stop [this] "Remove event listeners")
(-on-navigate [this path] "Find a match for current routing path and call on-navigate callback")
(-get-path [this] "Get the current routing path, including query string and fragment")
(-href [this path] "Converts given routing path to browser location"))
;; This version listens for both pop-state and hash-change for
;; compatibility for old browsers not supporting History API.
(defrecord FragmentHistory [on-navigate router popstate-listener hashchange-listener last-fragment]
History
(-init [this]
;; Link clicks and e.g. back button trigger both events, if fragment is same as previous ignore second event.
;; For old browsers only the hash-change event is triggered.
(let [last-fragment (atom nil)
this (assoc this :last-fragment last-fragment)
handler (fn [e]
(let [path (-get-path this)]
(when (or (= goog.events.EventType.POPSTATE (.-type e))
(not= @last-fragment path))
(-on-navigate this path))))
;; rfe start! uses first on-navigate call to store the
;; instance so it has to see the instance with listeners.
this (assoc this
:popstate-listener (gevents/listen js/window goog.events.EventType.POPSTATE handler false)
:hashchange-listener (gevents/listen js/window goog.events.EventType.HASHCHANGE handler false))]
(-on-navigate this (-get-path this))
this))
(-stop [this]
(gevents/unlistenByKey popstate-listener)
(gevents/unlistenByKey hashchange-listener)
nil)
(-on-navigate [this path]
(reset! last-fragment path)
(on-navigate (rf/match-by-path router path this) this))
(-get-path [this]
;; Remove #
;; "" or "#" should be same as "#/"
(let [fragment (subs (.. js/window -location -hash) 1)]
(if (= "" fragment)
"/"
fragment)))
(-href [this path]
(if path
(str "#" path))))
(defn- closest-by-tag [el tag]
;; nodeName is upper case for HTML always,
;; for XML or XHTML it would be in the original case.
(let [tag (.toUpperCase tag)]
(loop [el el]
(if el
(if (= tag (.-nodeName el))
el
(recur (.-parentNode el)))))))
(defn- event-target
"Read event's target from composed path to get shadow dom working,
fallback to target property if not available"
[event]
(let [original-event (.getBrowserEvent event)]
(if (exists? (.-composedPath original-event))
(aget (.composedPath original-event) 0)
(.-target event))))
(defn ignore-anchor-click?
"Precicate to check if the anchor click event default action
should be ignored. This logic will ignore the event
if anchor href matches the route tree, and in this case
the page location is updated using History API."
[router e el uri]
(let [current-domain (if (exists? js/location)
(.getDomain (.parse goog.Uri js/location)))]
(and (or (and (not (.hasScheme uri)) (not (.hasDomain uri)))
(= current-domain (.getDomain uri)))
(not (.-altKey e))
(not (.-ctrlKey e))
(not (.-metaKey e))
(not (.-shiftKey e))
(or (not (.hasAttribute el "target"))
(contains? #{"" "_self"} (.getAttribute el "target")))
;; Left button
(= 0 (.-button e))
;; isContentEditable property is inherited from parents,
;; so if the anchor is inside contenteditable div, the property will be true.
(not (.-isContentEditable el))
;; NOTE: Why doesn't this use frontend variant instead of core?
(reitit/match-by-path router (.getPath uri)))))
(defrecord Html5History [on-navigate router listen-key click-listen-key]
History
(-init [this]
(let [handler
(fn [e]
(-on-navigate this (-get-path this)))
ignore-anchor-click-predicate (or (:ignore-anchor-click? this)
ignore-anchor-click?)
;; Prevent document load when clicking a elements, if the href points to URL that is part
;; of the routing tree."
ignore-anchor-click (fn [e]
;; Returns the next matching ancestor of event target
(when-let [el (closest-by-tag (event-target e) "a")]
(let [uri (.parse goog.Uri (.-href el))]
(when (ignore-anchor-click-predicate router e el uri)
(.preventDefault e)
(let [path (str (.getPath uri)
(when (.hasQuery uri)
(str "?" (.getQuery uri)))
(when (.hasFragment uri)
(str "#" (.getFragment uri))))]
(.pushState js/window.history nil "" path)
(-on-navigate this path))))))
this (assoc this
:listen-key (gevents/listen js/window goog.events.EventType.POPSTATE handler false)
:click-listen-key (gevents/listen js/document goog.events.EventType.CLICK ignore-anchor-click))]
(-on-navigate this (-get-path this))
this))
(-on-navigate [this path]
(on-navigate (rf/match-by-path router path this) this))
(-stop [this]
(gevents/unlistenByKey listen-key)
(gevents/unlistenByKey click-listen-key)
nil)
(-get-path [this]
(str (.. js/window -location -pathname)
(.. js/window -location -search)
(.. js/window -location -hash)))
(-href [this path]
path))
(defn start!
"This registers event listeners on HTML5 history and hashchange events.
Returns History object.
When using with development workflow like Figwheel, remember to
remove listeners using stop! call before calling start! again.
Parameters:
- router The Reitit router.
- on-navigate Function to be called when route changes. Takes two parameters, ´match´ and ´history´ object.
Options:
- :use-fragment (default true) If true, onhashchange and location hash are used to store current route.
Options (Html5History):
- :ignore-anchor-click? Function (router, event, anchor element, uri) which will be called to
check if the anchor click event should be ignored.
To extend built-in check, you can call `reitit.frontend.history/ignore-anchor-click?`
function, which will ignore clicks if the href matches route tree."
([router on-navigate]
(start! router on-navigate nil))
([router
on-navigate
{:keys [use-fragment]
:or {use-fragment true}
:as opts}]
(let [opts (-> opts
(dissoc :use-fragment)
(assoc :router router
:on-navigate on-navigate))]
(-init (if use-fragment
(map->FragmentHistory opts)
(map->Html5History opts))))))
(defn stop!
"Stops the given history handler, removing the event handlers."
[history]
(if history
(-stop history)))
(defn
^{:see-also ["reitit.core/match->path"]}
href
"Generate a URL for a route defined by name, with given path-params and query-params.
The URL is formatted using Reitit frontend history handler, so using it with
anchor element href will correctly trigger route change event.
Note: currently collections in query parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first."
([history name]
(href history name nil))
([history name path-params]
(href history name path-params nil))
([history name path-params query-params]
(href history name path-params query-params nil))
([history name path-params query-params fragment]
(let [match (rf/match-by-name! (:router history) name path-params)]
(-href history (rf/match->path match query-params fragment)))))
(defn
^{:see-also ["reitit.core/match->path"]}
push-state
"Updates the browser URL and pushes new entry to the history stack using
a route defined by name, with given path-params and query-params.
Will also trigger on-navigate callback on Reitit frontend History handler.
Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState"
([history name]
(push-state history name nil nil nil))
([history name path-params]
(push-state history name path-params nil nil))
([history name path-params query-params]
(push-state history name path-params query-params nil))
([history name path-params query-params fragment]
(let [match (rf/match-by-name! (:router history) name path-params)
path (rf/match->path match query-params fragment)]
;; pushState and replaceState don't trigger popstate event so call on-navigate manually
(.pushState js/window.history nil "" (-href history path))
(-on-navigate history path))))
(defn
^{:see-also ["reitit.core/match->path"]}
replace-state
"Updates the browser location and replaces latest entry in the history stack
using URL built from a route defined by name, with given path-params and
query-params.
Will also trigger on-navigate callback on Reitit frontend History handler.
Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState"
([history name]
(replace-state history name nil nil nil))
([history name path-params]
(replace-state history name path-params nil nil))
([history name path-params query-params]
(replace-state history name path-params query-params nil))
([history name path-params query-params fragment]
(let [match (rf/match-by-name! (:router history) name path-params)
path (rf/match->path match query-params fragment)]
(.replaceState js/window.history nil "" (-href history path))
(-on-navigate history path))))
(defn
^{:see-also ["reitit.core/match->path"]}
navigate
"Updates the browser location and either pushes new entry to the history stack
or replaces the latest entry in the the history stack (controlled by
`replace` option) using URL built from a route defined by name given
parameters.
Will also trigger on-navigate callback on Reitit frontend History handler.
Note: currently collections in query-parameters are encoded as field-value
pairs separated by &, i.e. \"?a=1&a=2\", if you want to encode them
differently, convert the collections to strings first.
See also:
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState
https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState"
([history name]
(navigate history name nil))
([history name {:keys [path-params query-params fragment replace] :as opts}]
(let [match (rf/match-by-name! (:router history) name path-params)
path (rf/match->path match query-params fragment)]
(if replace
(.replaceState js/window.history nil "" (-href history path))
(.pushState js/window.history nil "" (-href history path)))
(-on-navigate history path))))
(defn
^{:see-also ["reitit.frontend/set-query-params"]}
set-query
"Update query parameters for the current route.
New query params can be given as a map, or a function taking
the old params and returning the new modified params.
Note: The query parameter values aren't coereced, so the
update fn will see string values for all query params."
([history new-query-or-update-fn]
(set-query history new-query-or-update-fn nil))
([history new-query-or-update-fn {:keys [replace] :as opts}]
(let [current-path (-get-path history)
new-path (rf/set-query-params current-path new-query-or-update-fn)]
(if replace
(.replaceState js/window.history nil "" (-href history new-path))
(.pushState js/window.history nil "" (-href history new-path)))
(-on-navigate history new-path))))